refacto item-tool

This commit is contained in:
wass08
2026-01-28 07:47:47 +09:00
parent e27028a29a
commit 8646cc8748
7 changed files with 856 additions and 466 deletions
+192 -464
View File
@@ -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
}