({
+ 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'