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:
@@ -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 />
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user