diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index eddc2a83..5592be3d 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -20,7 +20,14 @@ import type { AnyNode } from '../schema/types' // Base event interfaces export interface GridEvent { + /** World-space intersection point on the grid plane. */ 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 } @@ -97,6 +104,11 @@ type PresetEvents = { 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } } +type ThumbnailEvents = { + 'thumbnail:before-capture': undefined + 'thumbnail:after-capture': undefined +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'item', ItemEvent> & @@ -114,6 +126,7 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & - PresetEvents + PresetEvents & + ThumbnailEvents export const emitter = mitt() diff --git a/packages/editor/src/components/editor/floating-building-action-menu.tsx b/packages/editor/src/components/editor/floating-building-action-menu.tsx new file mode 100644 index 00000000..5802618d --- /dev/null +++ b/packages/editor/src/components/editor/floating-building-action-menu.tsx @@ -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(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 ( + + + e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + /> + + + ) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 2c12132b..37133fdd 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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} /> - + @@ -9601,6 +9614,7 @@ export function FloorplanPanel() { vectorEffect="non-scaling-stroke" /> )} + )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 0fe8d27c..910bd7f8 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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 && } {!isVersionPreviewMode && !isFirstPersonMode && } {!isVersionPreviewMode && !isFirstPersonMode && } + {!isVersionPreviewMode && !isFirstPersonMode && } {!isFirstPersonMode && } {isFirstPersonMode ? : } diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index b383a997..95696b16 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -4,7 +4,7 @@ import { Copy, Move, Trash2 } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { - onDelete: MouseEventHandler + onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler onMove?: MouseEventHandler onPointerDown?: PointerEventHandler @@ -52,15 +52,17 @@ export function NodeActionMenu({ )} - + {onDelete && ( + + )} ) } diff --git a/packages/editor/src/components/tools/building/move-building-tool.tsx b/packages/editor/src/components/tools/building/move-building-tool.tsx new file mode 100644 index 00000000..0a1e96fd --- /dev/null +++ b/packages/editor/src/components/tools/building/move-building-tool.tsx @@ -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(node.rotation[1] ?? 0) + const pendingRotationRef = useRef(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 ( + + + + ) +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index e4fc607e..3ea47ddf 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -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 if (movingNode.type === 'door') return if (movingNode.type === 'window') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 1395cb6d..7d146499 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -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], diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 4ae114b8..caaa6572 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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(null!) const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) + const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( 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) diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx index 1f246bff..1d1de3fa 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx @@ -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] diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index cb1a77e9..83f4e3b7 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -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( diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 4180aff4..51399f3c 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -139,8 +139,8 @@ export const PolygonEditor: React.FC = ({ // 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 diff --git a/packages/editor/src/components/tools/slab/slab-tool.tsx b/packages/editor/src/components/tools/slab/slab-tool.tsx index 0ce67007..4f48a141 100644 --- a/packages/editor/src/components/tools/slab/slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/slab-tool.tsx @@ -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) => { diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 17786757..a9093611 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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) } diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index bf82bae5..c806c502 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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 && } - {showZoneBoundaryEditor && selectedZoneId && } - {showSlabBoundaryEditor && selectedSlabId && } - {showSlabHoleEditor && selectedSlabId && editingHole && ( - - )} - {showCeilingBoundaryEditor && selectedCeilingId && ( - - )} - {showCeilingHoleEditor && selectedCeilingId && editingHole && ( - - )} - {movingNode && } - {!movingNode && BuildToolComponent && } + {/* World-space tools: site boundary and building movement operate in world coordinates */} + {movingNode?.type === 'building' && } + + {/* 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. */} + + {showZoneBoundaryEditor && selectedZoneId && } + {showSlabBoundaryEditor && selectedSlabId && } + {showSlabHoleEditor && selectedSlabId && editingHole && ( + + )} + {showCeilingBoundaryEditor && selectedCeilingId && ( + + )} + {showCeilingHoleEditor && selectedCeilingId && editingHole && ( + + )} + {movingNode && movingNode.type !== 'building' && } + {!movingNode && BuildToolComponent && } + ) } diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index 7dd467be..debf4e6e 100755 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -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 } diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index 46ae0ab3..427f60a9 100755 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -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 diff --git a/packages/editor/src/components/ui/helpers/building-helper.tsx b/packages/editor/src/components/ui/helpers/building-helper.tsx new file mode 100644 index 00000000..d7b6fc21 --- /dev/null +++ b/packages/editor/src/components/ui/helpers/building-helper.tsx @@ -0,0 +1,32 @@ +import { ShortcutToken } from '../primitives/shortcut-token' + +interface BuildingHelperProps { + showRotate?: boolean +} + +export function BuildingHelper({ showRotate }: BuildingHelperProps) { + return ( +
+
+ + Place building +
+ {showRotate && ( + <> +
+ + Rotate counterclockwise +
+
+ + Rotate clockwise +
+ + )} +
+ + Cancel +
+
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index b2b6cd3c..bd62e410 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -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 return } diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index e4588112..9c1a22c8 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -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 } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index ae2950dd..dd4dd2f0 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( | RoofSegmentNode | StairNode | StairSegmentNode + | BuildingNode | null, setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/viewer/src/components/renderers/building/building-renderer.tsx index 3e835feb..bc53b56b 100644 --- a/packages/viewer/src/components/renderers/building/building-renderer.tsx +++ b/packages/viewer/src/components/renderers/building/building-renderer.tsx @@ -10,7 +10,12 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { useRegistry(node.id, node.type, ref) const handlers = useNodeEvents(node, 'building') return ( - + {node.children.map((childId) => ( ))}