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