diff --git a/apps/editor/public/icons/measure.svg b/apps/editor/public/icons/measure.svg new file mode 100644 index 00000000..9dfaebab --- /dev/null +++ b/apps/editor/public/icons/measure.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/editor/src/components/tools/measure/measure-tool.tsx b/packages/editor/src/components/tools/measure/measure-tool.tsx new file mode 100644 index 00000000..f49deb30 --- /dev/null +++ b/packages/editor/src/components/tools/measure/measure-tool.tsx @@ -0,0 +1,314 @@ +import { emitter, type GridEvent } from '@pascal-app/core' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, type Group, type Line, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapSegmentTo45Degrees, + snapToGrid, +} from '../shared/drawing-utils' + +type MeasureState = { + start: PlanPoint | null + end: PlanPoint | null + isLocked: boolean + levelY: number +} + +const syncLineGeometry = ( + line: Line, + start: PlanPoint | null, + end: PlanPoint | null, + y: number, +) => { + if (!(start && end)) { + line.visible = false + return + } + + if (getPlanDistance(start, end) < MIN_DRAW_DISTANCE) { + line.visible = false + return + } + + const points = [new Vector3(start[0], y + 0.02, start[1]), new Vector3(end[0], y + 0.02, end[1])] + + line.geometry.dispose() + line.geometry = new BufferGeometry().setFromPoints(points) + line.visible = true +} + +export const MeasureTool: React.FC = () => { + const cursorRef = useRef(null) + const lineRef = useRef(null!) + const startRef = useRef(null) + const endRef = useRef(null) + const isLockedRef = useRef(false) + const shiftPressed = useRef(false) + const previousEndRef = useRef(null) + const inputOpenRef = useRef(false) + const levelYRef = useRef(0) + const ignoreNextGridClickRef = useRef(false) + + const [measurement, setMeasurement] = useState({ + start: null, + end: null, + isLocked: false, + levelY: 0, + }) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const syncMeasurementState = useCallback((levelY: number) => { + levelYRef.current = levelY + setMeasurement({ + start: startRef.current, + end: endRef.current, + isLocked: isLockedRef.current, + levelY, + }) + }, []) + + const applyDistanceInput = (rawValue: string, options?: { ignoreNextGridClick?: boolean }) => { + if (!(startRef.current && endRef.current)) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const nextEnd = projectPointAtDistance(startRef.current, endRef.current, parsedDistance) + endRef.current = nextEnd + previousEndRef.current = nextEnd + cursorRef.current?.position.set(nextEnd[0], levelYRef.current, nextEnd[1]) + syncLineGeometry(lineRef.current, startRef.current, nextEnd, levelYRef.current) + syncMeasurementState(levelYRef.current) + closeDistanceInput(options) + } + + useEffect(() => { + lineRef.current.geometry = new BufferGeometry() + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return + + const levelY = event.position[1] + const gridPosition: PlanPoint = [snapToGrid(event.position[0]), snapToGrid(event.position[2])] + + if (!(startRef.current && !isLockedRef.current)) { + cursorRef.current.position.set(gridPosition[0], levelY, gridPosition[1]) + return + } + + if (inputOpenRef.current) return + + const nextEnd = shiftPressed.current + ? gridPosition + : snapSegmentTo45Degrees(startRef.current, gridPosition) + + if ( + previousEndRef.current && + (nextEnd[0] !== previousEndRef.current[0] || nextEnd[1] !== previousEndRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + previousEndRef.current = nextEnd + endRef.current = nextEnd + cursorRef.current.position.set(nextEnd[0], levelY, nextEnd[1]) + syncLineGeometry(lineRef.current, startRef.current, nextEnd, levelY) + syncMeasurementState(levelY) + } + + const onGridClick = (event: GridEvent) => { + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + if (inputOpenRef.current) return + + const levelY = event.position[1] + const gridPosition: PlanPoint = [snapToGrid(event.position[0]), snapToGrid(event.position[2])] + + if (!startRef.current || isLockedRef.current) { + startRef.current = gridPosition + endRef.current = gridPosition + isLockedRef.current = false + previousEndRef.current = gridPosition + cursorRef.current?.position.set(gridPosition[0], levelY, gridPosition[1]) + syncLineGeometry(lineRef.current, null, null, levelY) + syncMeasurementState(levelY) + return + } + + const finalEnd = endRef.current ?? gridPosition + if (getPlanDistance(startRef.current, finalEnd) < MIN_DRAW_DISTANCE) return + + endRef.current = finalEnd + isLockedRef.current = true + syncLineGeometry(lineRef.current, startRef.current, finalEnd, levelY) + syncMeasurementState(levelY) + } + + const onCancel = () => { + startRef.current = null + endRef.current = null + isLockedRef.current = false + previousEndRef.current = null + ignoreNextGridClickRef.current = false + closeDistanceInput() + if (lineRef.current.geometry) { + lineRef.current.visible = false + } + setMeasurement({ start: null, end: null, isLocked: false, levelY: 0 }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + if (event.key === 'Shift') { + shiftPressed.current = true + return + } + + if (event.key !== 'Tab') return + if (!(startRef.current && endRef.current && !isLockedRef.current)) return + + const currentDistance = getPlanDistance(startRef.current, endRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + event.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: currentDistance.toFixed(2), + }) + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressed.current = false + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + closeDistanceInput() + } + }, [closeDistanceInput, syncMeasurementState]) + + const currentDistance = useMemo(() => { + if (!(measurement.start && measurement.end)) return 0 + return getPlanDistance(measurement.start, measurement.end) + }, [measurement.end, measurement.start]) + + const labelPosition = useMemo(() => { + if (!(measurement.start && measurement.end)) return null + const midpoint = getPlanMidpoint(measurement.start, measurement.end) + return [midpoint[0], measurement.levelY + 0.18, midpoint[1]] as [number, number, number] + }, [measurement.end, measurement.levelY, measurement.start]) + + return ( + + + + {/* @ts-ignore R3F line type mismatches DOM line typing */} + + + + + + {measurement.start && ( + + )} + + {measurement.isLocked && measurement.end && ( + + )} + + {labelPosition && currentDistance >= MIN_DRAW_DISTANCE && ( + { + if (!distanceInput.open) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={labelPosition} + value={formatDistance(currentDistance)} + /> + )} + + ) +} diff --git a/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx b/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx new file mode 100644 index 00000000..b18b434c --- /dev/null +++ b/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx @@ -0,0 +1,79 @@ +import { Html } from '@react-three/drei' +import { useEffect, useRef } from 'react' + +interface DrawingDimensionLabelProps { + position: [number, number, number] + value: string + isEditing?: boolean + inputValue?: string + inputLabel?: string + hint?: string + onInputBlur?: () => void + onInputChange?: (value: string) => void + onInputKeyDown?: (event: React.KeyboardEvent) => void +} + +export function DrawingDimensionLabel({ + position, + value, + isEditing = false, + inputValue = '', + inputLabel = 'Distance', + hint = 'Enter to apply', + onInputBlur, + onInputChange, + onInputKeyDown, +}: DrawingDimensionLabelProps) { + const inputRef = useRef(null) + + useEffect(() => { + if (!isEditing) return + + const id = requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + + return () => cancelAnimationFrame(id) + }, [isEditing]) + + return ( + +
{ + event.stopPropagation() + }} + > + {isEditing ? ( +
+
+ {inputLabel} +
+
+ onInputChange?.(event.target.value)} + onKeyDown={onInputKeyDown} + ref={inputRef} + type="text" + value={inputValue} + /> + m +
+
{hint}
+
+ ) : ( +
{value}
+ )} +
+ + ) +} diff --git a/packages/editor/src/components/tools/shared/drawing-utils.ts b/packages/editor/src/components/tools/shared/drawing-utils.ts new file mode 100644 index 00000000..8368a443 --- /dev/null +++ b/packages/editor/src/components/tools/shared/drawing-utils.ts @@ -0,0 +1,70 @@ +export type PlanPoint = [number, number] + +export const GRID_STEP = 0.5 +export const MIN_DRAW_DISTANCE = 0.01 + +export const snapToGrid = (value: number) => Math.round(value / GRID_STEP) * GRID_STEP + +export const getPlanDistance = (start: PlanPoint, end: PlanPoint) => { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + return Math.hypot(dx, dz) +} + +export const getPlanMidpoint = (start: PlanPoint, end: PlanPoint): PlanPoint => [ + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2, +] + +export const projectPointAtDistance = ( + start: PlanPoint, + target: PlanPoint, + distance: number, +): PlanPoint => { + const dx = target[0] - start[0] + const dz = target[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < MIN_DRAW_DISTANCE) { + return [start[0] + distance, start[1]] + } + + const unitX = dx / length + const unitZ = dz / length + + return [start[0] + unitX * distance, start[1] + unitZ * distance] +} + +export const formatDistance = (distance: number) => { + if (!Number.isFinite(distance)) return '--' + const precision = distance >= 10 ? 1 : 2 + return `${distance.toFixed(precision)}m` +} + +export const parseDistanceInput = (value: string) => { + const normalized = value + .trim() + .toLowerCase() + .replace(/meters?$/, '') + .replace(/m$/, '') + .trim() + if (!normalized) return null + + const parsed = Number.parseFloat(normalized) + if (!Number.isFinite(parsed)) return null + + return parsed +} + +export const snapSegmentTo45Degrees = (start: PlanPoint, cursor: PlanPoint): PlanPoint => { + const dx = cursor[0] - start[0] + const dz = cursor[1] - start[1] + const angle = Math.atan2(dz, dx) + const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) + const distance = Math.hypot(dx, dz) + + return [ + snapToGrid(start[0] + Math.cos(snappedAngle) * distance), + snapToGrid(start[1] + Math.sin(snappedAngle) * distance), + ] +} diff --git a/packages/editor/src/components/tools/slab/slab-tool.tsx b/packages/editor/src/components/tools/slab/slab-tool.tsx index 2226b836..b521db89 100644 --- a/packages/editor/src/components/tools/slab/slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/slab-tool.tsx @@ -1,10 +1,21 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapToGrid, +} from '../shared/drawing-utils' const Y_OFFSET = 0.02 @@ -72,11 +83,57 @@ export const SlabTool: React.FC = () => { const setSelection = useViewer((state) => state.setSelection) const [points, setPoints] = useState>([]) - const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) const previousSnappedPointRef = useRef<[number, number] | null>(null) const shiftPressed = useRef(false) + const pointsRef = useRef>([]) + const cursorPositionRef = useRef([0, 0]) + const snappedCursorPositionRef = useRef([0, 0]) + const levelYRef = useRef(0) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) + + const updatePoints = useCallback((nextPoints: Array) => { + pointsRef.current = nextPoints + setPoints(nextPoints) + }, []) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const applyDistanceInput = (rawValue: string, options?: { ignoreNextGridClick?: boolean }) => { + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const projected = projectPointAtDistance( + lastPoint, + snappedCursorPositionRef.current, + parsedDistance, + ) + cursorPositionRef.current = projected + snappedCursorPositionRef.current = projected + previousSnappedPointRef.current = projected + setSnappedCursorPosition(projected) + cursorRef.current?.position.set(projected[0], levelYRef.current, projected[1]) + closeDistanceInput(options) + } // Update cursor position and lines on grid move useEffect(() => { @@ -85,24 +142,28 @@ export const SlabTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] + const gridX = snapToGrid(event.position[0]) + const gridZ = snapToGrid(event.position[2]) + const gridPosition: PlanPoint = [gridX, gridZ] + + if (inputOpenRef.current) return - setCursorPosition(gridPosition) + cursorPositionRef.current = gridPosition + levelYRef.current = event.position[1] setLevelY(event.position[1]) // Calculate snapped display position (bypass snap when Shift is held) - const lastPoint = points[points.length - 1] + const lastPoint = pointsRef.current[pointsRef.current.length - 1] const displayPoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) + snappedCursorPositionRef.current = displayPoint setSnappedCursorPosition(displayPoint) // Play snap sound when the snapped position actually changes (only when drawing) if ( - points.length > 0 && + pointsRef.current.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1]) @@ -116,25 +177,32 @@ export const SlabTool: React.FC = () => { const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + if (inputOpenRef.current) return // Use the last displayed snapped position (respects Shift state from onGridMove) - const clickPoint = previousSnappedPointRef.current ?? cursorPosition + const clickPoint = previousSnappedPointRef.current ?? cursorPositionRef.current // Check if clicking on the first point to close the shape - const firstPoint = points[0] + const firstPoint = pointsRef.current[0] if ( - points.length >= 3 && + pointsRef.current.length >= 3 && firstPoint && Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { // Create the slab and select it - const slabId = commitSlabDrawing(currentLevelId, points) + const slabId = commitSlabDrawing(currentLevelId, pointsRef.current) setSelection({ selectedIds: [slabId] }) - setPoints([]) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() } else { // Add point to polygon - setPoints([...points, clickPoint]) + updatePoints([...pointsRef.current, clickPoint]) } } @@ -142,19 +210,44 @@ export const SlabTool: React.FC = () => { if (!currentLevelId) return // Need at least 3 points to form a polygon - if (points.length >= 3) { - const slabId = commitSlabDrawing(currentLevelId, points) + if (pointsRef.current.length >= 3) { + const slabId = commitSlabDrawing(currentLevelId, pointsRef.current) setSelection({ selectedIds: [slabId] }) - setPoints([]) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() } } const onCancel = () => { - setPoints([]) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + + if (e.key === 'Shift') { + shiftPressed.current = true + return + } + + if (e.key !== 'Tab' || pointsRef.current.length === 0) return + + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) return + + const currentDistance = getPlanDistance(lastPoint, snappedCursorPositionRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + e.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: currentDistance.toFixed(2), + }) } const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false @@ -175,7 +268,7 @@ export const SlabTool: React.FC = () => { emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setSelection]) + }, [currentLevelId, setSelection, closeDistanceInput, updatePoints]) // Update line geometries when points change useEffect(() => { @@ -246,6 +339,19 @@ export const SlabTool: React.FC = () => { return shape }, [points, snappedCursorPosition]) + const currentSegment = useMemo(() => { + const lastPoint = points[points.length - 1] + if (!lastPoint) return null + + const distance = getPlanDistance(lastPoint, snappedCursorPosition) + if (distance < MIN_DRAW_DISTANCE) return null + + return { + distance, + midpoint: getPlanMidpoint(lastPoint, snappedCursorPosition), + } + }, [points, snappedCursorPosition]) + return ( {/* Cursor */} @@ -313,6 +419,33 @@ export const SlabTool: React.FC = () => { showTooltip={false} /> ))} + + {currentSegment && ( + { + if (!distanceInput.open) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={[currentSegment.midpoint[0], levelY + 0.18, currentSegment.midpoint[1]]} + value={formatDistance(currentSegment.distance)} + /> + )} ) } diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0d2fde96..f1d4ceab 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -7,6 +7,7 @@ import { CeilingTool } from './ceiling/ceiling-tool' import { DoorTool } from './door/door-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' +import { MeasureTool } from './measure/measure-tool' import { RoofTool } from './roof/roof-tool' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' @@ -22,6 +23,7 @@ const tools: Record>> = { 'property-line': SiteBoundaryEditor, }, structure: { + measure: MeasureTool, wall: WallTool, slab: SlabTool, ceiling: CeilingTool, diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index eb0d45bf..f4195498 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,40 +1,30 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapSegmentTo45Degrees, + snapToGrid, +} from '../shared/drawing-utils' const WALL_HEIGHT = 2.5 const WALL_THICKNESS = 0.15 -/** - * Snap point to 45° angle increments relative to start point - * Also snaps end point to 0.5 grid - */ -const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => { - const dx = cursor.x - start.x - const dz = cursor.z - start.z - - // Calculate angle in radians - const angle = Math.atan2(dz, dx) - - // Round to nearest 45° (π/4 radians) - const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) - - // Calculate distance from start to cursor - const distance = Math.sqrt(dx * dx + dz * dz) - - // Project end point along snapped angle - let snappedX = start.x + Math.cos(snappedAngle) * distance - let snappedZ = start.z + Math.sin(snappedAngle) * distance - - // Snap to 0.5 grid - snappedX = Math.round(snappedX * 2) / 2 - snappedZ = Math.round(snappedZ * 2) / 2 - - return new Vector3(snappedX, cursor.y, snappedZ) +type WallDraft = { + start: PlanPoint | null + end: PlanPoint | null + levelY: number } /** @@ -104,29 +94,83 @@ export const WallTool: React.FC = () => { const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const levelYRef = useRef(0) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) + + const [draft, setDraft] = useState({ + start: null, + end: null, + levelY: 0, + }) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const syncDraftState = useCallback(() => { + setDraft({ + start: [startingPoint.current.x, startingPoint.current.z], + end: [endingPoint.current.x, endingPoint.current.z], + levelY: levelYRef.current, + }) + }, []) + + const applyDistanceInput = (rawValue: string, options?: { ignoreNextGridClick?: boolean }) => { + if (buildingState.current !== 1) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const start: PlanPoint = [startingPoint.current.x, startingPoint.current.z] + const currentEnd: PlanPoint = [endingPoint.current.x, endingPoint.current.z] + const projected = projectPointAtDistance(start, currentEnd, parsedDistance) + + endingPoint.current.set(projected[0], levelYRef.current, projected[1]) + cursorRef.current?.position.set(projected[0], levelYRef.current, projected[1]) + updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + syncDraftState() + closeDistanceInput(options) + } useEffect(() => { - let gridPosition: [number, number] = [0, 0] - let previousWallEnd: [number, number] | null = null + let gridPosition: PlanPoint = [0, 0] + let previousWallEnd: PlanPoint | null = null const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return - gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2] - const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1]) + gridPosition = [snapToGrid(event.position[0]), snapToGrid(event.position[2])] + levelYRef.current = event.position[1] + const cursorPosition: PlanPoint = [gridPosition[0], gridPosition[1]] if (buildingState.current === 1) { - // Snap to 45° angles only if shift is not pressed + if (inputOpenRef.current) return + + const start: PlanPoint = [startingPoint.current.x, startingPoint.current.z] const snapped = shiftPressed.current ? cursorPosition - : snapTo45Degrees(startingPoint.current, cursorPosition) - endingPoint.current.copy(snapped) + : snapSegmentTo45Degrees(start, cursorPosition) + + endingPoint.current.set(snapped[0], event.position[1], snapped[1]) // Position the cursor at the end of the wall being drawn - cursorRef.current.position.set(snapped.x, snapped.y, snapped.z) + cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]) // Play snap sound only when the actual wall end position changes - const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z] + const currentWallEnd: PlanPoint = [endingPoint.current.x, endingPoint.current.z] if ( previousWallEnd && (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) @@ -137,6 +181,7 @@ export const WallTool: React.FC = () => { // Update wall preview geometry updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + syncDraftState() } else { // Not drawing a wall, just follow the grid position cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) @@ -144,27 +189,58 @@ export const WallTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + + if (inputOpenRef.current) return + if (buildingState.current === 0) { startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1]) + endingPoint.current.copy(startingPoint.current) + levelYRef.current = event.position[1] buildingState.current = 1 wallPreviewRef.current.visible = true + syncDraftState() } else if (buildingState.current === 1) { const dx = endingPoint.current.x - startingPoint.current.x const dz = endingPoint.current.z - startingPoint.current.z - if (dx * dx + dz * dz < 0.01 * 0.01) return + if (dx * dx + dz * dz < MIN_DRAW_DISTANCE * MIN_DRAW_DISTANCE) return commitWallDrawing( [startingPoint.current.x, startingPoint.current.z], [endingPoint.current.x, endingPoint.current.z], ) wallPreviewRef.current.visible = false buildingState.current = 0 + closeDistanceInput() + setDraft({ start: null, end: null, levelY: 0 }) } } const onKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === 'Shift') { shiftPressed.current = true + return } + + if (e.key !== 'Tab' || buildingState.current !== 1) return + + const currentDistance = getPlanDistance( + [startingPoint.current.x, startingPoint.current.z], + [endingPoint.current.x, endingPoint.current.z], + ) + if (currentDistance < MIN_DRAW_DISTANCE) return + + e.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: currentDistance.toFixed(2), + }) } const onKeyUp = (e: KeyboardEvent) => { @@ -177,6 +253,8 @@ export const WallTool: React.FC = () => { if (buildingState.current === 1) { buildingState.current = 0 wallPreviewRef.current.visible = false + closeDistanceInput() + setDraft({ start: null, end: null, levelY: 0 }) } } @@ -193,7 +271,18 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [closeDistanceInput, syncDraftState]) + + const currentDistance = useMemo(() => { + if (!(draft.start && draft.end)) return 0 + return getPlanDistance(draft.start, draft.end) + }, [draft.end, draft.start]) + + const labelPosition = useMemo(() => { + if (!(draft.start && draft.end)) return null + const midpoint = getPlanMidpoint(draft.start, draft.end) + return [midpoint[0], draft.levelY + WALL_HEIGHT + 0.3, midpoint[1]] as [number, number, number] + }, [draft.end, draft.levelY, draft.start]) return ( @@ -212,6 +301,33 @@ export const WallTool: React.FC = () => { transparent /> + + {labelPosition && currentDistance >= MIN_DRAW_DISTANCE && ( + { + if (!distanceInput.open) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={labelPosition} + value={formatDistance(currentDistance)} + /> + )} ) } diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 80270e5d..a0ac2d1e 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -19,6 +19,7 @@ export type ToolConfig = { } export const tools: ToolConfig[] = [ + { id: 'measure', iconSrc: '/icons/measure.svg', label: 'Measure' }, { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' }, // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index b2b6cd3c..da3d990e 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -3,6 +3,7 @@ import useEditor from '../../../store/use-editor' import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' +import { MeasureHelper } from './measure-helper' import { RoofHelper } from './roof-helper' import { SlabHelper } from './slab-helper' import { WallHelper } from './wall-helper' @@ -19,6 +20,8 @@ export function HelperManager() { switch (tool) { case 'wall': return + case 'measure': + return case 'item': return case 'slab': diff --git a/packages/editor/src/components/ui/helpers/measure-helper.tsx b/packages/editor/src/components/ui/helpers/measure-helper.tsx new file mode 100644 index 00000000..cdbbbc35 --- /dev/null +++ b/packages/editor/src/components/ui/helpers/measure-helper.tsx @@ -0,0 +1,18 @@ +export function MeasureHelper() { + return ( +
+
+ Shift + Allow non-45° angles +
+
+ Tab + Type a measurement distance +
+
+ Esc + Clear the measurement +
+
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/slab-helper.tsx b/packages/editor/src/components/ui/helpers/slab-helper.tsx index bf9b6aad..6104a7e1 100644 --- a/packages/editor/src/components/ui/helpers/slab-helper.tsx +++ b/packages/editor/src/components/ui/helpers/slab-helper.tsx @@ -5,6 +5,10 @@ export function SlabHelper() { Shift Allow non-45° angles +
+ Tab + Type an exact segment length +
Esc Cancel diff --git a/packages/editor/src/components/ui/helpers/wall-helper.tsx b/packages/editor/src/components/ui/helpers/wall-helper.tsx index f15b3ac1..3d04cca4 100644 --- a/packages/editor/src/components/ui/helpers/wall-helper.tsx +++ b/packages/editor/src/components/ui/helpers/wall-helper.tsx @@ -5,6 +5,10 @@ export function WallHelper() { Shift Allow non-45° angles
+
+ Tab + Type an exact wall length +
Esc Cancel diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 0663ebe5..5b2bfb4a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -38,6 +38,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { keys: ['3'], action: 'Switch to Furnish phase' }, { keys: ['S'], action: 'Switch to Structure layer' }, { keys: ['F'], action: 'Switch to Furnish layer' }, + { keys: ['M'], action: 'Jump to the Measure tool' }, { keys: ['Z'], action: 'Switch to Zones layer' }, { keys: ['Cmd/Ctrl', 'Arrow Up'], @@ -79,9 +80,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { keys: ['Shift'], - action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings', + action: 'Temporarily disable angle snapping while drawing or measuring', note: 'Hold while drawing.', }, + { + keys: ['Tab'], + action: 'Type an exact wall, slab segment, or measurement distance', + note: 'Available while a draw segment is active.', + }, ], }, { diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index 50beff88..3a8f2e01 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -16,7 +16,15 @@ export function useContextualTools() { } // Default tools when nothing is selected - const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window'] + const defaultTools: StructureTool[] = [ + 'measure', + 'wall', + 'slab', + 'ceiling', + 'roof', + 'door', + 'window', + ] if (selection.selectedIds.length === 0) { return defaultTools @@ -29,12 +37,12 @@ export function useContextualTools() { // If a wall is selected, prioritize wall-hosted elements if (selectedTypes.has('wall')) { - return ['window', 'door', 'wall'] as StructureTool[] + return ['measure', 'window', 'door', 'wall'] as StructureTool[] } // If a slab is selected, prioritize slab editing if (selectedTypes.has('slab')) { - return ['slab', 'wall'] as StructureTool[] + return ['measure', 'slab', 'wall'] as StructureTool[] } // If a ceiling is selected, prioritize ceiling editing diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index b558331c..b1443bb4 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -38,6 +38,12 @@ export const useKeyboard = () => { } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('furnish') + } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('build') + useEditor.getState().setTool('measure') } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('structure') diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 2542d632..5be7f8fc 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -19,6 +19,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build' // Structure mode tools (building elements) export type StructureTool = + | 'measure' | 'wall' | 'room' | 'custom-room'