refacto item-tool
This commit is contained in:
@@ -1,489 +1,261 @@
|
||||
import {
|
||||
type CeilingEvent,
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
ItemNode,
|
||||
isObject,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
useSpatialQuery,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
|
||||
|
||||
/**
|
||||
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
|
||||
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
||||
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
||||
*/
|
||||
function snapToGrid(position: number, dimension: number): number {
|
||||
// Check if half the dimension has a 0.25 remainder (odd multiple of 0.5)
|
||||
const halfDim = dimension / 2
|
||||
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
|
||||
const offset = needsOffset ? 0.25 : 0
|
||||
// Snap to 0.5 grid with offset
|
||||
return Math.round((position - offset) * 2) / 2 + offset
|
||||
}
|
||||
|
||||
const stripTransient = (meta: any) => {
|
||||
if (!isObject(meta)) return meta
|
||||
const { isTransient, ...rest } = meta as Record<string, any>
|
||||
return rest
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate cursor rotation in WORLD space from wall normal and orientation
|
||||
*/
|
||||
const calculateCursorRotation = (
|
||||
normal: [number, number, number] | undefined,
|
||||
wallStart: [number, number],
|
||||
wallEnd: [number, number],
|
||||
): number => {
|
||||
if (!normal) return 0
|
||||
|
||||
// Wall direction angle in world XZ plane
|
||||
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
|
||||
|
||||
// In local wall space, front face has normal.z < 0, back face has normal.z > 0
|
||||
if (normal[2] < 0) {
|
||||
return -wallAngle
|
||||
} else {
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate item rotation in WALL-LOCAL space from normal
|
||||
* Items are children of the wall mesh, so their rotation is relative to wall's local space
|
||||
*/
|
||||
const calculateItemRotation = (normal: [number, number, number] | undefined): number => {
|
||||
if (!normal) return 0
|
||||
|
||||
// In wall-local space: X along wall, Y up, Z perpendicular (thickness)
|
||||
// Front face (normal.z < 0): item faces -Z local → rotation = 0
|
||||
// Back face (normal.z > 0): item faces +Z local → rotation = PI
|
||||
return normal[2] < 0 ? 0 : Math.PI
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which side of the wall based on the normal vector
|
||||
* In wall-local space, the wall runs along X-axis, so the normal points along Z-axis
|
||||
* Positive Z normal = 'back', Negative Z normal = 'front' (flipped due to orientation fix)
|
||||
*/
|
||||
const getSideFromNormal = (normal: [number, number, number] | undefined): 'front' | 'back' => {
|
||||
if (!normal) return 'front'
|
||||
// The Z component of the normal determines which side
|
||||
// Flipped: positive Z = back, negative Z = front
|
||||
return normal[2] >= 0 ? 'back' : 'front'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the normal indicates a valid wall side face (front or back)
|
||||
* Filters out top face and thickness edges
|
||||
*
|
||||
* In wall-local geometry space (after ExtrudeGeometry + rotateX):
|
||||
* - X axis: along wall direction
|
||||
* - Y axis: up (height)
|
||||
* - Z axis: perpendicular to wall (thickness direction)
|
||||
*
|
||||
* So valid side faces have normals pointing in ±Z direction (local space)
|
||||
*/
|
||||
const isValidWallSideFace = (normal: [number, number, number] | undefined): boolean => {
|
||||
if (!normal) return false
|
||||
|
||||
// Valid side faces have normals pointing in the local Z direction (perpendicular to wall)
|
||||
// This filters out top faces (Y direction) and end caps/junctions (X direction)
|
||||
return Math.abs(normal[2]) > 0.7
|
||||
}
|
||||
import {
|
||||
ceilingStrategy,
|
||||
checkCanPlace,
|
||||
floorStrategy,
|
||||
wallStrategy,
|
||||
} from './placement-strategies'
|
||||
import type { PlacementState, TransitionResult } from './placement-types'
|
||||
import { useDraftNode } from './use-draft-node'
|
||||
|
||||
export const ItemTool: React.FC = () => {
|
||||
const cursorRef = useRef<Mesh>(null!)
|
||||
const draftItem = useRef<ItemNode | null>(null)
|
||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||
const placementState = useRef<PlacementState>({
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
ceilingId: null,
|
||||
})
|
||||
|
||||
const selectedItem = useEditor((state) => state.selectedItem)
|
||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||
const isOnWall = useRef(false)
|
||||
const isOnCeiling = useRef(false)
|
||||
const draftNode = useDraftNode()
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItem) {
|
||||
return
|
||||
}
|
||||
if (!selectedItem) return
|
||||
|
||||
let currentWallId: string | null = null
|
||||
let currentCeilingId: string | null = null
|
||||
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||
|
||||
const checkCanPlace = () => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (currentLevelId && draftItem.current) {
|
||||
let placeable = true
|
||||
if (draftItem.current.asset.attachTo === 'ceiling') {
|
||||
if (!isOnCeiling.current || !currentCeilingId) {
|
||||
placeable = false
|
||||
} else {
|
||||
const result = canPlaceOnCeiling(
|
||||
currentCeilingId as CeilingNode['id'],
|
||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
draftItem.current.asset.dimensions,
|
||||
draftItem.current.rotation,
|
||||
[draftItem.current.id],
|
||||
)
|
||||
placeable = result.valid
|
||||
}
|
||||
} else if (draftItem.current.asset.attachTo) {
|
||||
if (!isOnWall.current || !currentWallId) {
|
||||
placeable = false
|
||||
} else {
|
||||
const result = canPlaceOnWall(
|
||||
currentLevelId,
|
||||
currentWallId as WallNode['id'],
|
||||
gridPosition.current.x,
|
||||
gridPosition.current.y,
|
||||
draftItem.current.asset.dimensions,
|
||||
draftItem.current.asset.attachTo as 'wall' | 'wall-side',
|
||||
draftItem.current.side,
|
||||
[draftItem.current.id],
|
||||
)
|
||||
placeable = result.valid
|
||||
}
|
||||
} else {
|
||||
placeable = canPlaceOnFloor(
|
||||
currentLevelId,
|
||||
[gridPosition.current.x, 0, gridPosition.current.z],
|
||||
draftItem.current.asset.dimensions,
|
||||
[0, 0, 0],
|
||||
[draftItem.current.id],
|
||||
).valid
|
||||
}
|
||||
if (placeable) {
|
||||
;(cursorRef.current.material as MeshStandardMaterial).color.set('green')
|
||||
return true
|
||||
} else {
|
||||
;(cursorRef.current.material as MeshStandardMaterial).color.set('red')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
const createDraftItem = () => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) {
|
||||
return null
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
draftItem.current = ItemNode.parse({
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
name: selectedItem.name,
|
||||
// Reset placement state for new item
|
||||
placementState.current = { surface: 'floor', wallId: null, ceilingId: null }
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
const getContext = () => ({
|
||||
asset: selectedItem,
|
||||
metadata: {
|
||||
isTransient: true,
|
||||
},
|
||||
levelId: useViewer.getState().selection.levelId,
|
||||
draftItem: draftNode.current,
|
||||
gridPosition: gridPosition.current,
|
||||
state: { ...placementState.current },
|
||||
})
|
||||
useScene.getState().createNode(draftItem.current, currentLevelId)
|
||||
checkCanPlace()
|
||||
|
||||
const revalidate = (): boolean => {
|
||||
const placeable = checkCanPlace(getContext(), validators)
|
||||
;(cursorRef.current.material as MeshStandardMaterial).color.set(
|
||||
placeable ? 'green' : 'red',
|
||||
)
|
||||
return placeable
|
||||
}
|
||||
createDraftItem()
|
||||
|
||||
const applyTransition = (result: TransitionResult) => {
|
||||
Object.assign(placementState.current, result.stateUpdate)
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
cursorRef.current.position.set(...result.cursorPosition)
|
||||
cursorRef.current.rotation.y = result.cursorRotationY
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
Object.assign(draft, result.nodeUpdate)
|
||||
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
||||
}
|
||||
revalidate()
|
||||
}
|
||||
|
||||
// ---- Create initial draft ----
|
||||
|
||||
draftNode.create(gridPosition.current, selectedItem)
|
||||
revalidate()
|
||||
|
||||
// ---- Floor Handlers ----
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
const result = floorStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
if (isOnWall.current || isOnCeiling.current) return
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
cursorRef.current.position.set(...result.cursorPosition)
|
||||
|
||||
const [dimX, , dimZ] = selectedItem.dimensions
|
||||
gridPosition.current.set(
|
||||
snapToGrid(event.position[0], dimX),
|
||||
0,
|
||||
snapToGrid(event.position[2], dimZ),
|
||||
)
|
||||
cursorRef.current.position.set(
|
||||
gridPosition.current.x,
|
||||
event.position[1],
|
||||
gridPosition.current.z,
|
||||
)
|
||||
checkCanPlace()
|
||||
if (draftItem.current) {
|
||||
draftItem.current.position = [gridPosition.current.x, 0, gridPosition.current.z]
|
||||
}
|
||||
const draft = draftNode.current
|
||||
if (draft) draft.position = result.gridPosition
|
||||
|
||||
revalidate()
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (isOnWall.current || isOnCeiling.current) return
|
||||
const result = floorStrategy.click(getContext(), event, validators)
|
||||
if (!result) return
|
||||
|
||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, 0, gridPosition.current.z],
|
||||
metadata: stripTransient(draftItem.current.metadata),
|
||||
})
|
||||
draftItem.current = null
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
createDraftItem()
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
draftNode.create(gridPosition.current, selectedItem)
|
||||
revalidate()
|
||||
}
|
||||
|
||||
// ---- Wall Handlers ----
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (
|
||||
useViewer.getState().selection.levelId !==
|
||||
resolveLevelId(event.node, useScene.getState().nodes)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
draftItem.current?.asset.attachTo === 'wall' ||
|
||||
draftItem.current?.asset.attachTo === 'wall-side'
|
||||
) {
|
||||
console.log('Wall enter:', event.node.id, event.normal)
|
||||
// Ignore top face and thickness edges
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
isOnWall.current = true
|
||||
currentWallId = event.node.id
|
||||
|
||||
// Determine side and rotation from normal
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
gridPosition.current.set(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[1] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
)
|
||||
draftItem.current.parentId = event.node.id
|
||||
draftItem.current.side = side
|
||||
draftItem.current.rotation = [0, itemRotation, 0]
|
||||
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: event.node.id,
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
})
|
||||
cursorRef.current.rotation.y = cursorRotation
|
||||
checkCanPlace()
|
||||
}
|
||||
}
|
||||
|
||||
const onWallLeave = (event: WallEvent) => {
|
||||
if (!isOnWall.current) return
|
||||
isOnWall.current = false
|
||||
currentWallId = null
|
||||
event.stopPropagation()
|
||||
if (!draftItem.current) return
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
draftItem.current.parentId = currentLevelId
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: currentLevelId,
|
||||
})
|
||||
checkCanPlace()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!isOnWall.current) return
|
||||
|
||||
// Ignore top face and thickness edges
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
||||
|
||||
// Get side and rotation from current draft item (already set by onWallMove)
|
||||
const side = draftItem.current.side
|
||||
const rotation = draftItem.current.rotation
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: event.node.id,
|
||||
side,
|
||||
rotation,
|
||||
metadata: stripTransient(draftItem.current.metadata),
|
||||
})
|
||||
useScene.getState().dirtyNodes.add(event.node.id)
|
||||
draftItem.current = null
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
createDraftItem()
|
||||
checkCanPlace()
|
||||
applyTransition(result)
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (isOnWall.current === false) return
|
||||
if (!draftItem.current) return
|
||||
|
||||
// Ignore top face and thickness edges
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
const result = wallStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
cursorRef.current.position.set(...result.cursorPosition)
|
||||
cursorRef.current.rotation.y = result.cursorRotationY
|
||||
|
||||
// Determine side and rotation from normal
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
gridPosition.current.set(
|
||||
Math.round(event.localPosition[0] * 2) / 2,
|
||||
Math.round(event.localPosition[1] * 2) / 2,
|
||||
Math.round(event.localPosition[2] * 2) / 2,
|
||||
)
|
||||
cursorRef.current.position.set(
|
||||
Math.round(event.position[0] * 2) / 2,
|
||||
Math.round(event.position[1] * 2) / 2,
|
||||
Math.round(event.position[2] * 2) / 2,
|
||||
)
|
||||
cursorRef.current.rotation.y = cursorRotation
|
||||
|
||||
// Update draft item side and rotation
|
||||
draftItem.current.side = side
|
||||
draftItem.current.rotation = [0, itemRotation, 0]
|
||||
|
||||
const canPlace = checkCanPlace()
|
||||
if (draftItem.current && canPlace) {
|
||||
draftItem.current.position = [
|
||||
gridPosition.current.x,
|
||||
gridPosition.current.y,
|
||||
gridPosition.current.z,
|
||||
]
|
||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
||||
if (draftItemMesh) {
|
||||
draftItemMesh.position.copy(gridPosition.current)
|
||||
draftItemMesh.rotation.y = itemRotation
|
||||
// Sync side/rotation on draft ref (needed by checkCanPlace)
|
||||
const draft = draftNode.current
|
||||
if (draft && result.nodeUpdate) {
|
||||
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
||||
if ('rotation' in result.nodeUpdate)
|
||||
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
||||
}
|
||||
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
})
|
||||
useScene.getState().dirtyNodes.add(event.node.id)
|
||||
const placeable = revalidate()
|
||||
|
||||
// Only update mesh + store when placement is valid
|
||||
if (draft && placeable) {
|
||||
draft.position = result.gridPosition
|
||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||
if (mesh) {
|
||||
mesh.position.copy(gridPosition.current)
|
||||
const rot = result.nodeUpdate?.rotation
|
||||
if (rot) mesh.rotation.y = rot[1]
|
||||
}
|
||||
if (result.nodeUpdate) {
|
||||
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
||||
}
|
||||
if (result.dirtyNodeId) {
|
||||
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// CEILING HANDLERS
|
||||
// ====================================================================
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
const result = wallStrategy.click(getContext(), event, validators)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
if (result.dirtyNodeId) {
|
||||
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
||||
}
|
||||
draftNode.create(gridPosition.current, selectedItem)
|
||||
revalidate()
|
||||
}
|
||||
|
||||
const onWallLeave = (event: WallEvent) => {
|
||||
const result = wallStrategy.leave(getContext())
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
applyTransition(result)
|
||||
}
|
||||
|
||||
// ---- Ceiling Handlers ----
|
||||
|
||||
const onCeilingEnter = (event: CeilingEvent) => {
|
||||
if (draftItem.current?.asset.attachTo !== 'ceiling') return
|
||||
if (
|
||||
useViewer.getState().selection.levelId !==
|
||||
resolveLevelId(event.node, useScene.getState().nodes)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
isOnCeiling.current = true
|
||||
currentCeilingId = event.node.id
|
||||
|
||||
const [dimX, , dimZ] = selectedItem.dimensions
|
||||
const itemHeight = selectedItem.dimensions[1]
|
||||
|
||||
gridPosition.current.set(
|
||||
snapToGrid(event.position[0], dimX),
|
||||
-itemHeight,
|
||||
snapToGrid(event.position[2], dimZ),
|
||||
)
|
||||
cursorRef.current.position.set(
|
||||
gridPosition.current.x,
|
||||
event.position[1] - itemHeight,
|
||||
gridPosition.current.z,
|
||||
)
|
||||
|
||||
draftItem.current.parentId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: event.node.id,
|
||||
})
|
||||
checkCanPlace()
|
||||
applyTransition(result)
|
||||
}
|
||||
|
||||
const onCeilingMove = (event: CeilingEvent) => {
|
||||
if (!isOnCeiling.current || !draftItem.current) return
|
||||
const result = ceilingStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
cursorRef.current.position.set(...result.cursorPosition)
|
||||
|
||||
const [dimX, , dimZ] = selectedItem.dimensions
|
||||
const itemHeight = selectedItem.dimensions[1]
|
||||
revalidate()
|
||||
|
||||
gridPosition.current.set(
|
||||
snapToGrid(event.position[0], dimX),
|
||||
-itemHeight,
|
||||
snapToGrid(event.position[2], dimZ),
|
||||
)
|
||||
cursorRef.current.position.set(
|
||||
gridPosition.current.x,
|
||||
event.position[1] - itemHeight,
|
||||
gridPosition.current.z,
|
||||
)
|
||||
|
||||
checkCanPlace()
|
||||
if (draftItem.current) {
|
||||
draftItem.current.position = [
|
||||
gridPosition.current.x,
|
||||
gridPosition.current.y,
|
||||
gridPosition.current.z,
|
||||
]
|
||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
||||
if (draftItemMesh) {
|
||||
draftItemMesh.position.copy(gridPosition.current)
|
||||
}
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
draft.position = result.gridPosition
|
||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||
if (mesh) mesh.position.copy(gridPosition.current)
|
||||
}
|
||||
}
|
||||
|
||||
const onCeilingClick = (event: CeilingEvent) => {
|
||||
if (!isOnCeiling.current) return
|
||||
const result = ceilingStrategy.click(getContext(), event, validators)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: event.node.id,
|
||||
metadata: stripTransient(draftItem.current.metadata),
|
||||
})
|
||||
draftItem.current = null
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
createDraftItem()
|
||||
checkCanPlace()
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
draftNode.create(gridPosition.current, selectedItem)
|
||||
revalidate()
|
||||
}
|
||||
|
||||
const onCeilingLeave = (event: CeilingEvent) => {
|
||||
if (!isOnCeiling.current) return
|
||||
isOnCeiling.current = false
|
||||
currentCeilingId = null
|
||||
const result = ceilingStrategy.leave(getContext())
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (!draftItem.current) return
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
draftItem.current.parentId = currentLevelId
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
parentId: currentLevelId,
|
||||
})
|
||||
checkCanPlace()
|
||||
applyTransition(result)
|
||||
}
|
||||
|
||||
// ---- Keyboard rotation ----
|
||||
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const draft = draftNode.current
|
||||
if (!draft) return
|
||||
|
||||
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()
|
||||
const currentRotation = draft.rotation
|
||||
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
||||
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
||||
|
||||
useScene.getState().updateNode(draft.id, { rotation: draft.rotation })
|
||||
cursorRef.current.rotation.y = newRotationY
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
// ---- Bounding box geometry ----
|
||||
|
||||
const boxGeometry = new BoxGeometry(
|
||||
selectedItem.dimensions[0],
|
||||
selectedItem.dimensions[1],
|
||||
selectedItem.dimensions[2],
|
||||
)
|
||||
boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0)
|
||||
cursorRef.current.geometry = boxGeometry
|
||||
|
||||
// ---- Subscribe ----
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
@@ -495,71 +267,27 @@ export const ItemTool: React.FC = () => {
|
||||
emitter.on('ceiling:click', onCeilingClick)
|
||||
emitter.on('ceiling:leave', onCeilingLeave)
|
||||
|
||||
// Keyboard rotation handlers
|
||||
const ROTATION_STEP = Math.PI / 2 // 90 degrees
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!draftItem.current) return
|
||||
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') {
|
||||
rotationDelta = ROTATION_STEP // Counter-clockwise
|
||||
} else if (event.key === 't' || event.key === 'T') {
|
||||
rotationDelta = -ROTATION_STEP // Clockwise
|
||||
}
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
const currentRotation = draftItem.current.rotation
|
||||
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
||||
draftItem.current.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
||||
|
||||
useScene.getState().updateNode(draftItem.current.id, {
|
||||
rotation: draftItem.current.rotation,
|
||||
})
|
||||
|
||||
// Update cursor rotation to match
|
||||
cursorRef.current.rotation.y = newRotationY
|
||||
|
||||
checkCanPlace()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
const setupBoundingBox = () => {
|
||||
const boxGeometry = new BoxGeometry(
|
||||
selectedItem.dimensions[0],
|
||||
selectedItem.dimensions[1],
|
||||
selectedItem.dimensions[2],
|
||||
)
|
||||
boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0)
|
||||
cursorRef.current.geometry = boxGeometry
|
||||
}
|
||||
setupBoundingBox()
|
||||
|
||||
return () => {
|
||||
if (draftItem.current) {
|
||||
useScene.getState().deleteNode(draftItem.current.id)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
draftNode.destroy()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('ceiling:enter', onCeilingEnter)
|
||||
emitter.off('ceiling:move', onCeilingMove)
|
||||
emitter.off('ceiling:click', onCeilingClick)
|
||||
emitter.off('ceiling:leave', onCeilingLeave)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling])
|
||||
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (draftItem.current && !isOnWall.current && !isOnCeiling.current) {
|
||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
||||
if (draftItemMesh) {
|
||||
draftItemMesh.position.lerp(gridPosition.current, delta * 20)
|
||||
if (draftNode.current && placementState.current.surface === 'floor') {
|
||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||
if (mesh) {
|
||||
mesh.position.lerp(gridPosition.current, delta * 20)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { isObject } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
|
||||
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
||||
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
||||
*/
|
||||
export function snapToGrid(position: number, dimension: number): number {
|
||||
const halfDim = dimension / 2
|
||||
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
|
||||
const offset = needsOffset ? 0.25 : 0
|
||||
return Math.round((position - offset) * 2) / 2 + offset
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a value to 0.5 increments (used for wall-local positions).
|
||||
*/
|
||||
export function snapToHalf(value: number): number {
|
||||
return Math.round(value * 2) / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
||||
*/
|
||||
export function calculateCursorRotation(
|
||||
normal: [number, number, number] | undefined,
|
||||
wallStart: [number, number],
|
||||
wallEnd: [number, number],
|
||||
): number {
|
||||
if (!normal) return 0
|
||||
|
||||
// Wall direction angle in world XZ plane
|
||||
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
|
||||
|
||||
// In local wall space, front face has normal.z < 0, back face has normal.z > 0
|
||||
if (normal[2] < 0) {
|
||||
return -wallAngle
|
||||
} else {
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate item rotation in WALL-LOCAL space from normal.
|
||||
* Items are children of the wall mesh, so their rotation is relative to wall's local space.
|
||||
*/
|
||||
export function calculateItemRotation(normal: [number, number, number] | undefined): number {
|
||||
if (!normal) return 0
|
||||
|
||||
return normal[2] > 0 ? 0 : Math.PI
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which side of the wall based on the normal vector.
|
||||
* In wall-local space, the wall runs along X-axis, so the normal points along Z-axis.
|
||||
* Positive Z normal = 'front', Negative Z normal = 'back'
|
||||
*/
|
||||
export function getSideFromNormal(normal: [number, number, number] | undefined): 'front' | 'back' {
|
||||
if (!normal) return 'front'
|
||||
return normal[2] >= 0 ? 'front' : 'back'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the normal indicates a valid wall side face (front or back).
|
||||
* Filters out top face and thickness edges.
|
||||
*
|
||||
* In wall-local geometry space (after ExtrudeGeometry + rotateX):
|
||||
* - X axis: along wall direction
|
||||
* - Y axis: up (height)
|
||||
* - Z axis: perpendicular to wall (thickness direction)
|
||||
*
|
||||
* So valid side faces have normals pointing in ±Z direction (local space).
|
||||
*/
|
||||
export function isValidWallSideFace(normal: [number, number, number] | undefined): boolean {
|
||||
if (!normal) return false
|
||||
return Math.abs(normal[2]) > 0.7
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `isTransient` flag from node metadata before committing.
|
||||
*/
|
||||
export function stripTransient(meta: any): any {
|
||||
if (!isObject(meta)) return meta
|
||||
const { isTransient, ...rest } = meta as Record<string, any>
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
CeilingEvent,
|
||||
CeilingNode,
|
||||
GridEvent,
|
||||
WallEvent,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import type {
|
||||
CommitResult,
|
||||
LevelResolver,
|
||||
PlacementContext,
|
||||
PlacementResult,
|
||||
SpatialValidators,
|
||||
TransitionResult,
|
||||
} from './placement-types'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToGrid,
|
||||
snapToHalf,
|
||||
stripTransient,
|
||||
} from './placement-math'
|
||||
|
||||
// ============================================================================
|
||||
// FLOOR STRATEGY
|
||||
// ============================================================================
|
||||
|
||||
export const floorStrategy = {
|
||||
/**
|
||||
* Handle grid:move — update position when on floor surface.
|
||||
* Returns null if currently on wall/ceiling.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
|
||||
const [dimX, , dimZ] = ctx.asset.dimensions
|
||||
const x = snapToGrid(event.position[0], dimX)
|
||||
const z = snapToGrid(event.position[2], dimZ)
|
||||
|
||||
return {
|
||||
gridPosition: [x, 0, z],
|
||||
cursorPosition: [x, event.position[1], z],
|
||||
cursorRotationY: 0,
|
||||
nodeUpdate: { position: [x, 0, z] },
|
||||
stopPropagation: false,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle grid:click — commit placement on floor.
|
||||
* Returns null if on wall/ceiling or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: GridEvent, validators: SpatialValidators): CommitResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
|
||||
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
|
||||
const valid = validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
pos,
|
||||
ctx.draftItem.asset.dimensions,
|
||||
[0, 0, 0],
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
|
||||
if (!valid) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
position: pos,
|
||||
metadata: stripTransient(ctx.draftItem.metadata),
|
||||
},
|
||||
stopPropagation: false,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WALL STRATEGY
|
||||
// ============================================================================
|
||||
|
||||
export const wallStrategy = {
|
||||
/**
|
||||
* Handle wall:enter — transition from floor to wall surface.
|
||||
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
|
||||
*/
|
||||
enter(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
resolveLevelId: LevelResolver,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): TransitionResult | null {
|
||||
const attachTo = ctx.draftItem?.asset.attachTo
|
||||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
|
||||
// Level guard
|
||||
const wallLevelId = resolveLevelId(event.node, nodes)
|
||||
if (ctx.levelId !== wallLevelId) return null
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const x = snapToHalf(event.localPosition[0])
|
||||
const y = snapToHalf(event.localPosition[1])
|
||||
const z = snapToHalf(event.localPosition[2])
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'wall', wallId: event.node.id },
|
||||
nodeUpdate: {
|
||||
position: [x, y, z],
|
||||
parentId: event.node.id,
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
},
|
||||
cursorRotationY: cursorRotation,
|
||||
gridPosition: [x, y, z],
|
||||
cursorPosition: [x, y, z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle wall:move — update position while on wall.
|
||||
* Returns null if not on a wall or face is invalid.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: WallEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!ctx.draftItem) return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
return {
|
||||
gridPosition: [
|
||||
snapToHalf(event.localPosition[0]),
|
||||
snapToHalf(event.localPosition[1]),
|
||||
snapToHalf(event.localPosition[2]),
|
||||
],
|
||||
cursorPosition: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
snapToHalf(event.position[2]),
|
||||
],
|
||||
cursorRotationY: cursorRotation,
|
||||
nodeUpdate: {
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
},
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: event.node.id,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle wall:click — commit placement on wall.
|
||||
* Returns null if not on wall, face invalid, or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): CommitResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
|
||||
const valid = validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
ctx.state.wallId as WallNode['id'],
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
ctx.draftItem.asset.dimensions,
|
||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||
ctx.draftItem.side,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
|
||||
if (!valid) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
parentId: event.node.id,
|
||||
side: ctx.draftItem.side,
|
||||
rotation: ctx.draftItem.rotation,
|
||||
metadata: stripTransient(ctx.draftItem.metadata),
|
||||
},
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: event.node.id,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle wall:leave — transition back to floor surface.
|
||||
*/
|
||||
leave(ctx: PlacementContext): TransitionResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'floor', wallId: null },
|
||||
nodeUpdate: {
|
||||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
parentId: ctx.levelId,
|
||||
},
|
||||
cursorRotationY: 0,
|
||||
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CEILING STRATEGY
|
||||
// ============================================================================
|
||||
|
||||
export const ceilingStrategy = {
|
||||
/**
|
||||
* Handle ceiling:enter — transition from floor to ceiling surface.
|
||||
* Returns null if item doesn't attach to ceilings or wrong level.
|
||||
*/
|
||||
enter(
|
||||
ctx: PlacementContext,
|
||||
event: CeilingEvent,
|
||||
resolveLevelId: LevelResolver,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): TransitionResult | null {
|
||||
if (ctx.draftItem?.asset.attachTo !== 'ceiling') return null
|
||||
|
||||
// Level guard
|
||||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||||
if (ctx.levelId !== ceilingLevelId) return null
|
||||
|
||||
const [dimX, , dimZ] = ctx.asset.dimensions
|
||||
const itemHeight = ctx.asset.dimensions[1]
|
||||
|
||||
const x = snapToGrid(event.position[0], dimX)
|
||||
const z = snapToGrid(event.position[2], dimZ)
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
||||
nodeUpdate: {
|
||||
position: [x, -itemHeight, z],
|
||||
parentId: event.node.id,
|
||||
},
|
||||
cursorRotationY: 0,
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
cursorPosition: [x, event.position[1] - itemHeight, z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle ceiling:move — update position while on ceiling.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
const [dimX, , dimZ] = ctx.asset.dimensions
|
||||
const itemHeight = ctx.asset.dimensions[1]
|
||||
|
||||
const x = snapToGrid(event.position[0], dimX)
|
||||
const z = snapToGrid(event.position[2], dimZ)
|
||||
|
||||
return {
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
cursorPosition: [x, event.position[1] - itemHeight, z],
|
||||
cursorRotationY: 0,
|
||||
nodeUpdate: null,
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle ceiling:click — commit placement on ceiling.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
const pos: [number, number, number] = [
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
ctx.gridPosition.z,
|
||||
]
|
||||
|
||||
const valid = validators.canPlaceOnCeiling(
|
||||
ctx.state.ceilingId as CeilingNode['id'],
|
||||
pos,
|
||||
ctx.draftItem.asset.dimensions,
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
|
||||
if (!valid) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
position: pos,
|
||||
parentId: event.node.id,
|
||||
metadata: stripTransient(ctx.draftItem.metadata),
|
||||
},
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle ceiling:leave — transition back to floor surface.
|
||||
*/
|
||||
leave(ctx: PlacementContext): TransitionResult | null {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'floor', ceilingId: null },
|
||||
nodeUpdate: {
|
||||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
parentId: ctx.levelId,
|
||||
},
|
||||
cursorRotationY: 0,
|
||||
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Unified validation: check if the current draft item can be placed at its current position.
|
||||
* Switches on the active surface type and calls the appropriate spatial validator.
|
||||
*/
|
||||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||||
if (!ctx.levelId || !ctx.draftItem) return false
|
||||
|
||||
const attachTo = ctx.draftItem.asset.attachTo
|
||||
|
||||
if (attachTo === 'ceiling') {
|
||||
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
||||
return validators.canPlaceOnCeiling(
|
||||
ctx.state.ceilingId as CeilingNode['id'],
|
||||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
ctx.draftItem.asset.dimensions,
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
}
|
||||
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
|
||||
return validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
ctx.state.wallId as WallNode['id'],
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
ctx.draftItem.asset.dimensions,
|
||||
attachTo,
|
||||
ctx.draftItem.side,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
}
|
||||
|
||||
// Floor (no attachTo)
|
||||
return validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
||||
ctx.draftItem.asset.dimensions,
|
||||
[0, 0, 0],
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AnyNode, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core'
|
||||
import type { Vector3 } from 'three'
|
||||
import type { Asset } from '../../../../../packages/core/src/schema/nodes/item'
|
||||
|
||||
// ============================================================================
|
||||
// PLACEMENT STATE
|
||||
// ============================================================================
|
||||
|
||||
export type SurfaceType = 'floor' | 'wall' | 'ceiling'
|
||||
|
||||
/**
|
||||
* Tracks which surface the draft item is currently on.
|
||||
* Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables.
|
||||
*/
|
||||
export interface PlacementState {
|
||||
surface: SurfaceType
|
||||
wallId: string | null
|
||||
ceilingId: string | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STRATEGY CONTEXT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read-only snapshot passed to every strategy call.
|
||||
*/
|
||||
export interface PlacementContext {
|
||||
asset: Asset
|
||||
levelId: LevelNode['id'] | null
|
||||
draftItem: ItemNode | null
|
||||
gridPosition: Vector3
|
||||
state: PlacementState
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STRATEGY RESULTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Returned by strategy move handlers.
|
||||
*/
|
||||
export interface PlacementResult {
|
||||
gridPosition: [number, number, number]
|
||||
cursorPosition: [number, number, number]
|
||||
cursorRotationY: number
|
||||
nodeUpdate: Partial<ItemNode> | null
|
||||
stopPropagation: boolean
|
||||
dirtyNodeId: AnyNode['id'] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by enter/leave handlers (surface transitions).
|
||||
*/
|
||||
export interface TransitionResult {
|
||||
stateUpdate: Partial<PlacementState>
|
||||
nodeUpdate: Partial<ItemNode>
|
||||
gridPosition: [number, number, number]
|
||||
cursorPosition: [number, number, number]
|
||||
cursorRotationY: number
|
||||
stopPropagation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by click handlers (commit placement).
|
||||
*/
|
||||
export interface CommitResult {
|
||||
nodeUpdate: Partial<ItemNode>
|
||||
stopPropagation: boolean
|
||||
dirtyNodeId: AnyNode['id'] | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SPATIAL VALIDATORS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type for the useSpatialQuery() return value.
|
||||
*/
|
||||
export interface SpatialValidators {
|
||||
canPlaceOnFloor: (
|
||||
levelId: LevelNode['id'],
|
||||
position: [number, number, number],
|
||||
dimensions: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
ignoreIds?: string[],
|
||||
) => { valid: boolean }
|
||||
canPlaceOnWall: (
|
||||
levelId: LevelNode['id'],
|
||||
wallId: WallNode['id'],
|
||||
localX: number,
|
||||
localY: number,
|
||||
dimensions: [number, number, number],
|
||||
attachType: 'wall' | 'wall-side',
|
||||
side?: 'front' | 'back',
|
||||
ignoreIds?: string[],
|
||||
) => { valid: boolean }
|
||||
canPlaceOnCeiling: (
|
||||
ceilingId: CeilingNode['id'],
|
||||
position: [number, number, number],
|
||||
dimensions: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
ignoreIds?: string[],
|
||||
) => { valid: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver function type for finding a node's level.
|
||||
*/
|
||||
export type LevelResolver = (node: AnyNode, nodes: Record<string, AnyNode>) => string
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import type { Vector3 } from 'three'
|
||||
import type { Asset } from '../../../../../packages/core/src/schema/nodes/item'
|
||||
import { stripTransient } from './placement-math'
|
||||
|
||||
export interface DraftNodeHandle {
|
||||
/** Current draft item, or null */
|
||||
readonly current: ItemNode | null
|
||||
/** Create a new draft item at the given position. Returns the created node or null. */
|
||||
create: (gridPosition: Vector3, asset: Asset) => ItemNode | null
|
||||
/** Commit the current draft: resume temporal, strip transient, update node, clear ref. Returns committed ID. */
|
||||
commit: (finalUpdate: Partial<ItemNode>) => string | null
|
||||
/** Destroy the current draft: delete node, resume temporal. */
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages the lifecycle of a transient (draft) item node.
|
||||
* Handles temporal pause/resume for undo/redo isolation.
|
||||
*/
|
||||
export function useDraftNode(): DraftNodeHandle {
|
||||
const draftRef = useRef<ItemNode | null>(null)
|
||||
|
||||
const create = useCallback((gridPosition: Vector3, asset: Asset): ItemNode | null => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return null
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const node = ItemNode.parse({
|
||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||
name: asset.name,
|
||||
asset,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, currentLevelId)
|
||||
draftRef.current = node
|
||||
return node
|
||||
}, [])
|
||||
|
||||
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return null
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const update = {
|
||||
...finalUpdate,
|
||||
metadata: finalUpdate.metadata ?? stripTransient(draft.metadata),
|
||||
}
|
||||
|
||||
useScene.getState().updateNode(draft.id, update)
|
||||
const committedId = draft.id
|
||||
draftRef.current = null
|
||||
|
||||
return committedId
|
||||
}, [])
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
if (draftRef.current) {
|
||||
useScene.getState().deleteNode(draftRef.current.id)
|
||||
draftRef.current = null
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
get current() {
|
||||
return draftRef.current
|
||||
},
|
||||
create,
|
||||
commit,
|
||||
destroy,
|
||||
}),
|
||||
[create, commit, destroy],
|
||||
)
|
||||
}
|
||||
@@ -1079,7 +1079,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
0.5,
|
||||
0.5000000000000003
|
||||
],
|
||||
"attachTo": "wall"
|
||||
"attachTo": "wall-side"
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import type * as THREE from 'three'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
@@ -24,18 +24,22 @@ export const ItemSystem = () => {
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D
|
||||
if (!mesh) return
|
||||
|
||||
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
||||
if (item.asset.attachTo === 'wall-side') {
|
||||
// Wall-attached item: offset Z by half the parent wall's thickness
|
||||
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||
if (parentWall && parentWall.type === 'wall') {
|
||||
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
||||
mesh.position.z = wallThickness / 2
|
||||
const side = item.side === 'front' ? 1 : -1
|
||||
mesh.position.z = (wallThickness / 2) * side
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
// Floor item: elevate by slab height (using full footprint overlap)
|
||||
const levelId = resolveLevelId(item, nodes)
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId, item.position, item.asset.dimensions, item.rotation,
|
||||
levelId,
|
||||
item.position,
|
||||
item.asset.dimensions,
|
||||
item.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user