feat: move/rotate building + relative positioning for all tools (#220)

Sync monorepo PRs #243 and #248 to the open-source editor.

Move/Rotate Building:
- New move-building-tool with grid snapping, R/T rotation, and Esc cancel
- Floating building action menu with move button
- Building helper with keyboard shortcut hints
- BuildingRenderer now applies position and rotation from node data

Building-Relative Coordinate System:
- GridEvent gains localPosition (building-local coords)
- use-grid-events computes building-local intersection from building mesh
- ToolManager wraps building-relative tools in a building-local <group>
- All tools (wall, slab, ceiling, stair, roof, zone, polygon-editor,
  placement coordinator) updated to use event.localPosition
- Placement coordinator adds worldToBuildingLocal helper for cursor
  position conversion

2D Floorplan Fix:
- SVG layers wrapped in <g transform=rotate(...)> for building rotation
- Pointer-to-plan conversion un-rotates when building is rotated
- Grid events from 2D include localPosition

Store Changes:
- useEditor movingNode union includes BuildingNode
- NodeActionMenu onDelete is now optional (buildings can move but not delete)
This commit is contained in:
Pascal
2026-04-09 14:36:34 -04:00
committed by GitHub
parent 6c3ac3e030
commit 00b8f42899
22 changed files with 521 additions and 107 deletions
@@ -0,0 +1,69 @@
'use client'
import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useCallback, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
export function FloatingBuildingActionMenu() {
const buildingId = useViewer((s) => s.selection.buildingId)
const levelId = useViewer((s) => s.selection.levelId)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const groupRef = useRef<THREE.Group>(null)
useFrame(() => {
if (!(buildingId && !levelId && groupRef.current)) return
const obj = sceneRegistry.nodes.get(buildingId)
if (obj) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
groupRef.current.position.set(center.x, 1.5, center.z)
}
}
})
const handleMove = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!buildingId) return
const node = nodes[buildingId]
if (!node || node.type !== 'building') return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as BuildingNode)
setSelection({ buildingId: null })
},
[buildingId, nodes, setMovingNode, setSelection],
)
// Only show when a building is selected without a level
if (!buildingId || levelId) return null
return (
<group ref={groupRef}>
<Html
center
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
zIndexRange={[100, 0]}
>
<NodeActionMenu
onMove={handleMove}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
</Html>
</group>
)
}
@@ -4593,6 +4593,12 @@ export function FloorplanPanel() {
levelNode?.type === 'level' && levelNode.parentId
? (levelNode.parentId as BuildingNode['id'])
: (buildingId as BuildingNode['id'] | null)
const buildingRotationY = useScene((state) => {
if (!currentBuildingId) return 0
const node = state.nodes[currentBuildingId]
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
})
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const site = useScene((state) => {
for (const rootNodeId of state.rootNodeIds) {
const node = state.nodes[rootNodeId]
@@ -5963,9 +5969,14 @@ export function FloorplanPanel() {
return null
}
if (buildingRotationY !== 0) {
const [unrotX, unrotY] = rotatePlanVector(svgPoint.x, svgPoint.y, buildingRotationY)
return toPlanPointFromSvgPoint({ x: unrotX, y: unrotY })
}
return toPlanPointFromSvgPoint(svgPoint)
},
[getSvgPointFromClientPoint],
[getSvgPointFromClientPoint, buildingRotationY],
)
useEffect(() => {
siteBoundaryDraftRef.current = siteBoundaryDraft
@@ -6973,6 +6984,7 @@ export function FloorplanPanel() {
emitter.emit(`grid:${eventType}` as any, {
nativeEvent: nativeEvent.nativeEvent as any,
position: [snappedPoint[0], worldY, snappedPoint[1]],
localPosition: [snappedPoint[0], worldY, snappedPoint[1]],
})
return snappedPoint
@@ -9296,9 +9308,10 @@ export function FloorplanPanel() {
y={viewBox.minY}
/>
<FloorplanGridLayer
majorGridPath={majorGridPath}
minorGridPath={minorGridPath}
<g transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}>
<FloorplanGridLayer
majorGridPath={majorGridPath}
minorGridPath={minorGridPath}
palette={palette}
showGrid={showGrid}
/>
@@ -9601,6 +9614,7 @@ export function FloorplanPanel() {
vectorEffect="non-scaling-stroke"
/>
)}
</g>
</svg>
)}
</div>
@@ -55,6 +55,7 @@ import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
@@ -676,6 +677,7 @@ export default function Editor({
{!isFirstPersonMode && <SelectionManager />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -4,7 +4,7 @@ import { Copy, Move, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = {
onDelete: MouseEventHandler<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement>
@@ -52,15 +52,17 @@ export function NodeActionMenu({
<Copy className="h-4 w-4" />
</button>
)}
<button
aria-label="Delete"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
{onDelete && (
<button
aria-label="Delete"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
)
}
@@ -0,0 +1,157 @@
'use client'
import {
type BuildingNode,
emitter,
type GridEvent,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null)
// Stable refs so the effect never needs node in its dependency array
const nodeIdRef = useRef(node.id)
const originalPositionRef = useRef<[number, number, number]>([...node.position] as [
number,
number,
number,
])
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) {
const pos = new THREE.Vector3()
obj.getWorldPosition(pos)
return [pos.x, pos.y, pos.z]
}
return [node.position[0], node.position[1], node.position[2]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalPosition = originalPositionRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 2
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
pendingRotationRef.current += rotationDelta
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) mesh.rotation.y = pendingRotationRef.current
}
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([gridX, 0, gridZ])
// Directly update the Three.js group — no store update during drag
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.position.x = gridX
mesh.position.z = gridZ
}
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, {
position: [gridX, originalPosition[1], gridZ],
rotation: [0, pendingRotationRef.current, 0],
})
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
// Revert mesh position and rotation immediately
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.position.x = originalPosition[0]
mesh.position.z = originalPosition[2]
mesh.rotation.y = originalRotationRef.current
}
pendingRotationRef.current = originalRotationRef.current
// Restore building selection
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
useScene.temporal.getState().resume()
// Tell the keyboard handler we handled this, so it doesn't also clear the selection
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
if (!wasCommitted) {
useScene.getState().updateNode(nodeId, {
position: originalPosition,
rotation: [0, originalRotationRef.current, 0],
})
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [exitMoveMode]) // stable — node values captured via refs at mount
return (
<group>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
</group>
)
}
@@ -1,4 +1,5 @@
import type {
BuildingNode,
DoorNode,
ItemNode,
RoofNode,
@@ -10,6 +11,7 @@ import type {
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveWindowTool } from '../window/move-window-tool'
@@ -80,6 +82,8 @@ export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
@@ -47,12 +47,16 @@ export const floorStrategy = {
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
// event.localPosition is building-local; the coordinator cursor group is inside the
// building-local ToolManager group, so local coords are correct for both data and visuals.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
return {
gridPosition: [x, 0, z],
cursorPosition: [x, event.position[1], z],
cursorPosition: [x, event.localPosition[1], z],
cursorRotationY: 0,
nodeUpdate: { position: [x, 0, z] },
stopPropagation: false,
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
const dims = getScaledDimensions(ctx.draftItem)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
return {
gridPosition: [x, -itemHeight, z],
@@ -41,6 +41,7 @@ import {
wallStrategy,
} from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
import { snapToGrid } from './placement-math'
import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
@@ -83,6 +84,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const edgesRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
)
@@ -135,11 +137,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return placeable
}
// Tool visuals are rendered inside the building-local ToolManager group, so all cursor
// positions must be in building-local space. Wall/ceiling/item-surface strategies return
// world-space cursor positions (from their event.position); convert them here.
const worldToBuildingLocal = (x: number, y: number, z: number): Vector3 => {
const buildingId = useViewer.getState().selection.buildingId
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
}
const applyTransition = (result: TransitionResult) => {
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
const c = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(c.x, c.y, c.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
@@ -152,7 +164,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
const c = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(c.x, c.y, c.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
draftNode.create(
@@ -181,7 +194,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
mesh.getWorldPosition(cursorGroupRef.current.position)
const worldPos = new Vector3()
mesh.getWorldPosition(worldPos)
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
cursorGroupRef.current.position.copy(localPos)
// Extract world Y rotation (handles wall-parented items correctly)
const q = new Quaternion()
mesh.getWorldQuaternion(q)
@@ -216,6 +232,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorGroupRef.current.position.z = result.cursorPosition[2]
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
@@ -334,7 +351,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
const wc = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
@@ -486,7 +504,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const ic = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
@@ -511,14 +531,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation()
// Transition back to floor using event world position
const wx = Math.round(event.position[0] * 2) / 2
const wz = Math.round(event.position[2] * 2) / 2
// Transition back to floor using building-local position
const wx = Math.round(event.localPosition[0] * 2) / 2
const wz = Math.round(event.localPosition[2] * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz)
cursorGroupRef.current.position.set(wx, event.position[1], wz)
cursorGroupRef.current.position.x = wx
cursorGroupRef.current.position.z = wz
const draft = draftNode.current
if (draft) {
@@ -603,7 +624,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const cc = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
revalidate()
@@ -677,6 +700,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => {
// Don't intercept keys when focus is inside a text input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftFreeRef.current = true
revalidate()
@@ -702,11 +730,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY
// Update live transform rotation for 2D floorplan
// Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
const surface = placementState.current.surface
if (surface === 'floor' || surface === 'ceiling') {
const dims = getScaledDimensions(draft)
const [dimX, , dimZ] = dims
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
const x = snapToGrid(lastRawPos.current.x, swapDims ? dimZ : dimX)
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
gridPosition.current.set(x, gridPosition.current.y, z)
draft.position = [x, gridPosition.current.y, z]
cursorGroupRef.current.position.x = x
cursorGroupRef.current.position.z = z
if (mesh) {
mesh.position.x = x
mesh.position.z = z
}
} else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
if (surfaceMesh) {
const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
const dims = getScaledDimensions(draft)
const [dimX, , dimZ] = dims
const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
const y = gridPosition.current.y
gridPosition.current.set(x, y, z)
draft.position = [x, y, z]
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
const localSnapped = worldToBuildingLocal(
worldSnapped.x,
worldSnapped.y,
worldSnapped.z,
)
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
if (mesh) mesh.position.set(x, y, z)
}
}
// Update live transform for 2D floorplan with post-snap position
const currentLive = useLiveTransforms.getState().get(draft.id)
if (currentLive) {
useLiveTransforms.getState().set(draft.id, {
...currentLive,
position: [
cursorGroupRef.current.position.x,
cursorGroupRef.current.position.y,
cursorGroupRef.current.position.z,
] as [number, number, number],
rotation: newRotationY,
})
}
@@ -850,9 +922,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDraft = draftNode.current
const dims = initialDraft
? getScaledDimensions(initialDraft)
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
// Base plane geometry (colored rectangle on the ground)
@@ -156,7 +156,10 @@ export const MoveRoofTool: React.FC<{
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([gridX, y, gridZ])
// Cursor is inside the building-local ToolManager group — use local position
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y)
@@ -175,7 +178,7 @@ export const MoveRoofTool: React.FC<{
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
@@ -173,9 +173,9 @@ export const RoofTool: 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 y = event.position[1]
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
const gridY = y + GRID_OFFSET
@@ -206,9 +206,9 @@ export const RoofTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
if (corner1Ref.current) {
const roofId = commitRoofPlacement(
@@ -139,8 +139,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
@@ -86,12 +86,12 @@ 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 gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
setLevelY(event.localPosition[1])
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
@@ -112,7 +112,7 @@ export const SlabTool: React.FC = () => {
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => {
@@ -116,9 +116,9 @@ export const StairTool: React.FC = () => {
if (previewRef.current) previewRef.current.rotation.y = 0
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
if (cursorRef.current) {
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
@@ -141,9 +141,9 @@ export const StairTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1]
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
}
@@ -1,4 +1,10 @@
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type SlabNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
@@ -45,9 +51,18 @@ export const ToolManager: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group
// so their cursor positions and committed data are naturally in building-local space.
const building = buildingId
? (nodes[buildingId as AnyNodeId] as BuildingNode | undefined)
: undefined
const buildingPosition = building?.position ?? [0, 0, 0]
const buildingRotation = building?.rotation ?? [0, 0, 0]
// Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id']
@@ -102,19 +117,30 @@ export const ToolManager: React.FC = () => {
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
)}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{movingNode?.type === 'building' && <MoveTool />}
{/* Building-local group: all other tools are relative to the selected building.
Cursor visuals set positions in building-local space; this group applies the
building's world transform so they render at the correct world position. */}
<group
position={buildingPosition as [number, number, number]}
rotation={buildingRotation as [number, number, number]}
>
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
)}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</group>
</>
)
}
@@ -78,31 +78,28 @@ export const WallTool: React.FC = () => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
const walls = getCurrentLevelWalls()
const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]
gridPosition = snapWallDraftPoint({
point: cursorPoint,
walls,
})
// event.localPosition is building-local — consistent with stored wall start/end
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
if (buildingState.current === 1) {
const snappedPoint = snapWallDraftPoint({
point: cursorPoint,
const snappedLocal = snapWallDraftPoint({
point: localPoint,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])
endingPoint.current.copy(snapped)
// Position the cursor at the end of the wall being drawn
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
}
previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else {
// Not drawing a wall yet, show the snapped anchor point.
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
}
}
const onGridClick = (event: GridEvent) => {
const walls = getCurrentLevelWalls()
const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
if (buildingState.current === 0) {
const snappedStart = snapWallDraftPoint({
point: clickPoint,
walls,
})
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
wallPreviewRef.current.visible = true
} else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({
point: clickPoint,
point: localClick,
walls,
start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current,
})
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
)
// Both start and end are building-local ✓
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
wallPreviewRef.current.visible = false
buildingState.current = 0
}
@@ -174,18 +174,18 @@ export const ZoneTool: React.FC = () => {
if (!cursorRef.current) return
// Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
cursorPosition = [gridX, gridZ]
levelYRef.current = event.position[1]
levelYRef.current = event.localPosition[1]
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
} else {
cursorRef.current.position.set(gridX, event.position[1], gridZ)
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
}
updatePreview()
@@ -194,8 +194,8 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
let clickPoint: [number, number] = [gridX, gridZ]
// Snap to axis from last point
@@ -0,0 +1,32 @@
import { ShortcutToken } from '../primitives/shortcut-token'
interface BuildingHelperProps {
showRotate?: boolean
}
export function BuildingHelper({ showRotate }: BuildingHelperProps) {
return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Place building</span>
</div>
{showRotate && (
<>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="R" />
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="T" />
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
</>
)}
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper'
@@ -12,6 +13,7 @@ export function HelperManager() {
const movingNode = useEditor((state) => state.movingNode)
if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper showEsc />
}
+13 -1
View File
@@ -1,4 +1,10 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import {
type AnyNodeId,
type EventSuffix,
emitter,
type GridEvent,
sceneRegistry,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
@@ -44,9 +50,15 @@ export function useGridEvents(gridY: number) {
const point = getIntersection(nativeEvent)
if (!point) return
// Convert world-space point to building-local for tools that live inside a building.
const buildingId = useViewer.getState().selection.buildingId
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
const localPoint = buildingMesh ? buildingMesh.worldToLocal(point.clone()) : point
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = {
position: [point.x, point.y, point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z],
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
}
+3
View File
@@ -89,6 +89,7 @@ type EditorState = {
| RoofSegmentNode
| StairNode
| StairSegmentNode
| BuildingNode
| null
setMovingNode: (
node:
@@ -99,6 +100,7 @@ type EditorState = {
| RoofSegmentNode
| StairNode
| StairSegmentNode
| BuildingNode
| null,
) => void
selectedReferenceId: string | null
@@ -428,6 +430,7 @@ const useEditor = create<EditorState>()(
| RoofSegmentNode
| StairNode
| StairSegmentNode
| BuildingNode
| null,
setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null,