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
+14 -1
View File
@@ -20,7 +20,14 @@ import type { AnyNode } from '../schema/types'
// Base event interfaces // Base event interfaces
export interface GridEvent { export interface GridEvent {
/** World-space intersection point on the grid plane. */
position: [number, number, number] position: [number, number, number]
/**
* Building-local intersection point — relative to the currently selected building.
* Equals `position` when no building is selected.
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
*/
localPosition: [number, number, number]
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
@@ -97,6 +104,11 @@ type PresetEvents = {
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
} }
type ThumbnailEvents = {
'thumbnail:before-capture': undefined
'thumbnail:after-capture': undefined
}
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> & NodeEvents<'item', ItemEvent> &
@@ -114,6 +126,7 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
PresetEvents PresetEvents &
ThumbnailEvents
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>()
@@ -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?.type === 'level' && levelNode.parentId
? (levelNode.parentId as BuildingNode['id']) ? (levelNode.parentId as BuildingNode['id'])
: (buildingId as BuildingNode['id'] | null) : (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) => { const site = useScene((state) => {
for (const rootNodeId of state.rootNodeIds) { for (const rootNodeId of state.rootNodeIds) {
const node = state.nodes[rootNodeId] const node = state.nodes[rootNodeId]
@@ -5963,9 +5969,14 @@ export function FloorplanPanel() {
return null return null
} }
if (buildingRotationY !== 0) {
const [unrotX, unrotY] = rotatePlanVector(svgPoint.x, svgPoint.y, buildingRotationY)
return toPlanPointFromSvgPoint({ x: unrotX, y: unrotY })
}
return toPlanPointFromSvgPoint(svgPoint) return toPlanPointFromSvgPoint(svgPoint)
}, },
[getSvgPointFromClientPoint], [getSvgPointFromClientPoint, buildingRotationY],
) )
useEffect(() => { useEffect(() => {
siteBoundaryDraftRef.current = siteBoundaryDraft siteBoundaryDraftRef.current = siteBoundaryDraft
@@ -6973,6 +6984,7 @@ export function FloorplanPanel() {
emitter.emit(`grid:${eventType}` as any, { emitter.emit(`grid:${eventType}` as any, {
nativeEvent: nativeEvent.nativeEvent as any, nativeEvent: nativeEvent.nativeEvent as any,
position: [snappedPoint[0], worldY, snappedPoint[1]], position: [snappedPoint[0], worldY, snappedPoint[1]],
localPosition: [snappedPoint[0], worldY, snappedPoint[1]],
}) })
return snappedPoint return snappedPoint
@@ -9296,9 +9308,10 @@ export function FloorplanPanel() {
y={viewBox.minY} y={viewBox.minY}
/> />
<FloorplanGridLayer <g transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}>
majorGridPath={majorGridPath} <FloorplanGridLayer
minorGridPath={minorGridPath} majorGridPath={majorGridPath}
minorGridPath={minorGridPath}
palette={palette} palette={palette}
showGrid={showGrid} showGrid={showGrid}
/> />
@@ -9601,6 +9614,7 @@ export function FloorplanPanel() {
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
</g>
</svg> </svg>
)} )}
</div> </div>
@@ -55,6 +55,7 @@ import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2' import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu' import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel' import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid' import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
@@ -676,6 +677,7 @@ export default function Editor({
{!isFirstPersonMode && <SelectionManager />} {!isFirstPersonMode && <SelectionManager />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />} {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />} {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager /> <ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -4,7 +4,7 @@ import { Copy, Move, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
onDelete: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement> onMove?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement> onPointerDown?: PointerEventHandler<HTMLDivElement>
@@ -52,15 +52,17 @@ export function NodeActionMenu({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</button> </button>
)} )}
<button {onDelete && (
aria-label="Delete" <button
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" aria-label="Delete"
onClick={onDelete} className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title="Delete" onClick={onDelete}
type="button" title="Delete"
> type="button"
<Trash2 className="h-4 w-4" /> >
</button> <Trash2 className="h-4 w-4" />
</button>
)}
</div> </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 { import type {
BuildingNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
@@ -10,6 +11,7 @@ import type {
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveDoorTool } from '../door/move-door-tool' import { MoveDoorTool } from '../door/move-door-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveWindowTool } from '../window/move-window-tool' import { MoveWindowTool } from '../window/move-window-tool'
@@ -80,6 +82,8 @@ export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null 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 === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
@@ -47,12 +47,16 @@ export const floorStrategy = {
? getScaledDimensions(ctx.draftItem) ? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims const [dimX, , dimZ] = dims
const x = snapToGrid(event.position[0], dimX) const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const z = snapToGrid(event.position[2], dimZ) 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 { return {
gridPosition: [x, 0, z], gridPosition: [x, 0, z],
cursorPosition: [x, event.position[1], z], cursorPosition: [x, event.localPosition[1], z],
cursorRotationY: 0, cursorRotationY: 0,
nodeUpdate: { position: [x, 0, z] }, nodeUpdate: { position: [x, 0, z] },
stopPropagation: false, stopPropagation: false,
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const [dimX, , dimZ] = dims const [dimX, , dimZ] = dims
const itemHeight = dims[1] 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 x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], dimZ) const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
return { return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id }, stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
const dims = getScaledDimensions(ctx.draftItem) const dims = getScaledDimensions(ctx.draftItem)
const [dimX, , dimZ] = dims const [dimX, , dimZ] = dims
const itemHeight = dims[1] 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 x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], dimZ) const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
return { return {
gridPosition: [x, -itemHeight, z], gridPosition: [x, -itemHeight, z],
@@ -41,6 +41,7 @@ import {
wallStrategy, wallStrategy,
} from './placement-strategies' } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types' import type { PlacementState, TransitionResult } from './placement-types'
import { snapToGrid } from './placement-math'
import type { DraftNodeHandle } from './use-draft-node' import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] 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 edgesRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!) const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0)) const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>( const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
) )
@@ -135,11 +137,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return placeable 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) => { const applyTransition = (result: TransitionResult) => {
Object.assign(placementState.current, result.stateUpdate) Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition) 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 cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current const draft = draftNode.current
@@ -152,7 +164,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ensureDraft = (result: TransitionResult) => { const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition) 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 cursorGroupRef.current.rotation.y = result.cursorRotationY
draftNode.create( draftNode.create(
@@ -181,7 +194,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) { 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) // Extract world Y rotation (handles wall-parented items correctly)
const q = new Quaternion() const q = new Quaternion()
mesh.getWorldQuaternion(q) 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) // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorGroupRef.current.position.x = result.cursorPosition[0] cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorGroupRef.current.position.z = result.cursorPosition[2] cursorGroupRef.current.position.z = result.cursorPosition[2]
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const draft = draftNode.current const draft = draftNode.current
if (draft) draft.position = result.gridPosition if (draft) draft.position = result.gridPosition
@@ -334,7 +351,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
gridPosition.current.set(...result.gridPosition) 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 cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current const draft = draftNode.current
@@ -486,7 +504,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation() event.stopPropagation()
gridPosition.current.set(...result.gridPosition) 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 cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current const draft = draftNode.current
@@ -511,14 +531,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation() event.stopPropagation()
// Transition back to floor using event world position // Transition back to floor using building-local position
const wx = Math.round(event.position[0] * 2) / 2 const wx = Math.round(event.localPosition[0] * 2) / 2
const wz = Math.round(event.position[2] * 2) / 2 const wz = Math.round(event.localPosition[2] * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz] const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz) 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 const draft = draftNode.current
if (draft) { if (draft) {
@@ -603,7 +624,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
gridPosition.current.set(...result.gridPosition) 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() revalidate()
@@ -677,6 +700,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => { 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') { if (event.key === 'Shift') {
shiftFreeRef.current = true shiftFreeRef.current = true
revalidate() revalidate()
@@ -702,11 +730,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const mesh = sceneRegistry.nodes.get(draft.id) const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY 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) const currentLive = useLiveTransforms.getState().get(draft.id)
if (currentLive) { if (currentLive) {
useLiveTransforms.getState().set(draft.id, { useLiveTransforms.getState().set(draft.id, {
...currentLive, ...currentLive,
position: [
cursorGroupRef.current.position.x,
cursorGroupRef.current.position.y,
cursorGroupRef.current.position.z,
] as [number, number, number],
rotation: newRotationY, rotation: newRotationY,
}) })
} }
@@ -850,9 +922,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDraft = draftNode.current const initialDraft = draftNode.current
const dims = initialDraft const dims = initialDraft
? getScaledDimensions(initialDraft) ? getScaledDimensions(initialDraft)
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS) : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) 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) initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
// Base plane geometry (colored rectangle on the ground) // Base plane geometry (colored rectangle on the ground)
@@ -156,7 +156,10 @@ export const MoveRoofTool: React.FC<{
} }
previousGridPosRef.current = [gridX, gridZ] 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) const [localX, localZ] = computeLocal(gridX, gridZ, y)
@@ -175,7 +178,7 @@ export const MoveRoofTool: React.FC<{
} }
const onGridClick = (event: GridEvent) => { 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 gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
@@ -173,9 +173,9 @@ export const RoofTool: React.FC = () => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return if (!cursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.position[1] const y = event.localPosition[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ] const cursorPosition: [number, number, number] = [gridX, y, gridZ]
const gridY = y + GRID_OFFSET const gridY = y + GRID_OFFSET
@@ -206,9 +206,9 @@ export const RoofTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.position[1] const y = event.localPosition[1]
if (corner1Ref.current) { if (corner1Ref.current) {
const roofId = commitRoofPlacement( const roofId = commitRoofPlacement(
@@ -139,8 +139,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Listen to grid:move events to track cursor position // Listen to grid:move events to track cursor position
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const newPosition: [number, number] = [gridX, gridZ] const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag // 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) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return if (!cursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ] const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.position[1]) setLevelY(event.localPosition[1])
// Calculate snapped display position (bypass snap when Shift is held) // Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
@@ -112,7 +112,7 @@ export const SlabTool: React.FC = () => {
} }
previousSnappedPointRef.current = displayPoint 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) => { const onGridClick = (_event: GridEvent) => {
@@ -116,9 +116,9 @@ export const StairTool: React.FC = () => {
if (previewRef.current) previewRef.current.rotation.y = 0 if (previewRef.current) previewRef.current.rotation.y = 0
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.position[1] const y = event.localPosition[1]
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ) cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
@@ -141,9 +141,9 @@ export const StairTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.position[1] const y = event.localPosition[1]
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current) 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 { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '../../store/use-editor' import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
@@ -45,9 +51,18 @@ export const ToolManager: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes) 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 // Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id'] | SlabNode['id']
@@ -102,19 +117,30 @@ export const ToolManager: React.FC = () => {
return ( return (
<> <>
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />} {/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />} {movingNode?.type === 'building' && <MoveTool />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} /> {/* Building-local group: all other tools are relative to the selected building.
)} Cursor visuals set positions in building-local space; this group applies the
{showCeilingBoundaryEditor && selectedCeilingId && ( building's world transform so they render at the correct world position. */}
<CeilingBoundaryEditor ceilingId={selectedCeilingId} /> <group
)} position={buildingPosition as [number, number, number]}
{showCeilingHoleEditor && selectedCeilingId && editingHole && ( rotation={buildingRotation as [number, number, number]}
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> >
)} {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{movingNode && <MoveTool />} {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {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 gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null 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) => { const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return if (!(cursorRef.current && wallPreviewRef.current)) return
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]] // event.localPosition is building-local — consistent with stored wall start/end
gridPosition = snapWallDraftPoint({ const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
point: cursorPoint, gridPosition = snapWallDraftPoint({ point: localPoint, walls })
walls,
})
if (buildingState.current === 1) { if (buildingState.current === 1) {
const snappedPoint = snapWallDraftPoint({ const snappedLocal = snapWallDraftPoint({
point: cursorPoint, point: localPoint,
walls, walls,
start: [startingPoint.current.x, startingPoint.current.z], start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current, angleSnap: !shiftPressed.current,
}) })
const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
endingPoint.current.copy(snapped) cursorRef.current.position.copy(endingPoint.current)
// Position the cursor at the end of the wall being drawn
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
// Play snap sound only when the actual wall end position changes // 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 ( if (
previousWallEnd && previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
} }
previousWallEnd = currentWallEnd previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else { } else {
// Not drawing a wall yet, show the snapped anchor point. // 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 onGridClick = (event: GridEvent) => {
const walls = getCurrentLevelWalls() 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) { if (buildingState.current === 0) {
const snappedStart = snapWallDraftPoint({ const snappedStart = snapWallDraftPoint({ point: localClick, walls })
point: clickPoint,
walls,
})
gridPosition = snappedStart 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) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
wallPreviewRef.current.visible = true wallPreviewRef.current.visible = true
} else if (buildingState.current === 1) { } else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({ const snappedEnd = snapWallDraftPoint({
point: clickPoint, point: localClick,
walls, walls,
start: [startingPoint.current.x, startingPoint.current.z], start: [startingPoint.current.x, startingPoint.current.z],
angleSnap: !shiftPressed.current, angleSnap: !shiftPressed.current,
}) })
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1]) const dx = snappedEnd[0] - startingPoint.current.x
const dx = endingPoint.current.x - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return if (dx * dx + dz * dz < 0.01 * 0.01) return
createWallOnCurrentLevel( // Both start and end are building-local ✓
[startingPoint.current.x, startingPoint.current.z], createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
[endingPoint.current.x, endingPoint.current.z],
)
wallPreviewRef.current.visible = false wallPreviewRef.current.visible = false
buildingState.current = 0 buildingState.current = 0
} }
@@ -174,18 +174,18 @@ export const ZoneTool: React.FC = () => {
if (!cursorRef.current) return if (!cursorRef.current) return
// Snap to 0.5 grid // Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
cursorPosition = [gridX, gridZ] cursorPosition = [gridX, gridZ]
levelYRef.current = event.position[1] levelYRef.current = event.localPosition[1]
// If we have points, snap to axis from last point // If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1] const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) { if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition) 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 { } else {
cursorRef.current.position.set(gridX, event.position[1], gridZ) cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
} }
updatePreview() updatePreview()
@@ -194,8 +194,8 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
let clickPoint: [number, number] = [gridX, gridZ] let clickPoint: [number, number] = [gridX, gridZ]
// Snap to axis from last point // 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' 'use client'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper' import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
@@ -12,6 +13,7 @@ export function HelperManager() {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper showEsc /> 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 { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@@ -44,9 +50,15 @@ export function useGridEvents(gridY: number) {
const point = getIntersection(nativeEvent) const point = getIntersection(nativeEvent)
if (!point) return 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 eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = { const payload: GridEvent = {
position: [point.x, point.y, point.z], position: [point.x, point.y, point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z],
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
} }
+3
View File
@@ -89,6 +89,7 @@ type EditorState = {
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
| StairSegmentNode | StairSegmentNode
| BuildingNode
| null | null
setMovingNode: ( setMovingNode: (
node: node:
@@ -99,6 +100,7 @@ type EditorState = {
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
| StairSegmentNode | StairSegmentNode
| BuildingNode
| null, | null,
) => void ) => void
selectedReferenceId: string | null selectedReferenceId: string | null
@@ -428,6 +430,7 @@ const useEditor = create<EditorState>()(
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
| StairSegmentNode | StairSegmentNode
| BuildingNode
| null, | null,
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null, selectedReferenceId: null,
@@ -10,7 +10,12 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
useRegistry(node.id, node.type, ref) useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'building') const handlers = useNodeEvents(node, 'building')
return ( return (
<group ref={ref} {...handlers}> <group
position={node.position}
ref={ref}
rotation={[node.rotation[0], node.rotation[1], node.rotation[2]]}
{...handlers}
>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}