refacto item-tool
This commit is contained in:
@@ -1,489 +1,261 @@
|
|||||||
import {
|
import {
|
||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
type CeilingNode,
|
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
ItemNode,
|
|
||||||
isObject,
|
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
useSpatialQuery,
|
useSpatialQuery,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
type WallNode,
|
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three'
|
import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
|
||||||
|
import {
|
||||||
/**
|
ceilingStrategy,
|
||||||
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
|
checkCanPlace,
|
||||||
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
floorStrategy,
|
||||||
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
wallStrategy,
|
||||||
*/
|
} from './placement-strategies'
|
||||||
function snapToGrid(position: number, dimension: number): number {
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
// Check if half the dimension has a 0.25 remainder (odd multiple of 0.5)
|
import { useDraftNode } from './use-draft-node'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemTool: React.FC = () => {
|
export const ItemTool: React.FC = () => {
|
||||||
const cursorRef = useRef<Mesh>(null!)
|
const cursorRef = useRef<Mesh>(null!)
|
||||||
const draftItem = useRef<ItemNode | null>(null)
|
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
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 selectedItem = useEditor((state) => state.selectedItem)
|
||||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||||
const isOnWall = useRef(false)
|
const draftNode = useDraftNode()
|
||||||
const isOnCeiling = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentWallId: string | null = null
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||||
let currentCeilingId: string | null = null
|
|
||||||
|
|
||||||
const checkCanPlace = () => {
|
// Reset placement state for new item
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
placementState.current = { surface: 'floor', wallId: null, ceilingId: null }
|
||||||
if (currentLevelId && draftItem.current) {
|
|
||||||
let placeable = true
|
// ---- Helpers ----
|
||||||
if (draftItem.current.asset.attachTo === 'ceiling') {
|
|
||||||
if (!isOnCeiling.current || !currentCeilingId) {
|
const getContext = () => ({
|
||||||
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,
|
|
||||||
asset: selectedItem,
|
asset: selectedItem,
|
||||||
metadata: {
|
levelId: useViewer.getState().selection.levelId,
|
||||||
isTransient: true,
|
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) => {
|
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
|
const draft = draftNode.current
|
||||||
gridPosition.current.set(
|
if (draft) draft.position = result.gridPosition
|
||||||
snapToGrid(event.position[0], dimX),
|
|
||||||
0,
|
revalidate()
|
||||||
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 onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const result = floorStrategy.click(getContext(), event, validators)
|
||||||
if (isOnWall.current || isOnCeiling.current) return
|
if (!result) return
|
||||||
|
|
||||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
draftNode.create(gridPosition.current, selectedItem)
|
||||||
useScene.temporal.getState().resume()
|
revalidate()
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Wall Handlers ----
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
if (
|
const nodes = useScene.getState().nodes
|
||||||
useViewer.getState().selection.levelId !==
|
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
resolveLevelId(event.node, useScene.getState().nodes)
|
if (!result) return
|
||||||
) {
|
|
||||||
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
|
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
isOnWall.current = true
|
applyTransition(result)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (isOnWall.current === false) return
|
const result = wallStrategy.move(getContext(), event)
|
||||||
if (!draftItem.current) return
|
if (!result) return
|
||||||
|
|
||||||
// Ignore top face and thickness edges
|
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
|
||||||
|
|
||||||
event.stopPropagation()
|
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
|
// Sync side/rotation on draft ref (needed by checkCanPlace)
|
||||||
const side = getSideFromNormal(event.normal)
|
const draft = draftNode.current
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
if (draft && result.nodeUpdate) {
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
||||||
|
if ('rotation' in result.nodeUpdate)
|
||||||
gridPosition.current.set(
|
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
const placeable = revalidate()
|
||||||
side,
|
|
||||||
rotation: [0, itemRotation, 0],
|
// Only update mesh + store when placement is valid
|
||||||
})
|
if (draft && placeable) {
|
||||||
useScene.getState().dirtyNodes.add(event.node.id)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================================================================
|
const onWallClick = (event: WallEvent) => {
|
||||||
// CEILING HANDLERS
|
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) => {
|
const onCeilingEnter = (event: CeilingEvent) => {
|
||||||
if (draftItem.current?.asset.attachTo !== 'ceiling') return
|
const nodes = useScene.getState().nodes
|
||||||
if (
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
useViewer.getState().selection.levelId !==
|
if (!result) return
|
||||||
resolveLevelId(event.node, useScene.getState().nodes)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
isOnCeiling.current = true
|
applyTransition(result)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCeilingMove = (event: CeilingEvent) => {
|
const onCeilingMove = (event: CeilingEvent) => {
|
||||||
if (!isOnCeiling.current || !draftItem.current) return
|
const result = ceilingStrategy.move(getContext(), event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
cursorRef.current.position.set(...result.cursorPosition)
|
||||||
|
|
||||||
const [dimX, , dimZ] = selectedItem.dimensions
|
revalidate()
|
||||||
const itemHeight = selectedItem.dimensions[1]
|
|
||||||
|
|
||||||
gridPosition.current.set(
|
const draft = draftNode.current
|
||||||
snapToGrid(event.position[0], dimX),
|
if (draft) {
|
||||||
-itemHeight,
|
draft.position = result.gridPosition
|
||||||
snapToGrid(event.position[2], dimZ),
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
)
|
if (mesh) mesh.position.copy(gridPosition.current)
|
||||||
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 onCeilingClick = (event: CeilingEvent) => {
|
const onCeilingClick = (event: CeilingEvent) => {
|
||||||
if (!isOnCeiling.current) return
|
const result = ceilingStrategy.click(getContext(), event, validators)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
draftNode.commit(result.nodeUpdate)
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
draftNode.create(gridPosition.current, selectedItem)
|
||||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
revalidate()
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCeilingLeave = (event: CeilingEvent) => {
|
const onCeilingLeave = (event: CeilingEvent) => {
|
||||||
if (!isOnCeiling.current) return
|
const result = ceilingStrategy.leave(getContext())
|
||||||
isOnCeiling.current = false
|
if (!result) return
|
||||||
currentCeilingId = null
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!draftItem.current) return
|
applyTransition(result)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
@@ -495,71 +267,27 @@ export const ItemTool: React.FC = () => {
|
|||||||
emitter.on('ceiling:click', onCeilingClick)
|
emitter.on('ceiling:click', onCeilingClick)
|
||||||
emitter.on('ceiling:leave', onCeilingLeave)
|
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 () => {
|
return () => {
|
||||||
if (draftItem.current) {
|
draftNode.destroy()
|
||||||
useScene.getState().deleteNode(draftItem.current.id)
|
|
||||||
}
|
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
|
||||||
emitter.off('wall:click', onWallClick)
|
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
emitter.off('wall:click', onWallClick)
|
||||||
|
emitter.off('wall:leave', onWallLeave)
|
||||||
emitter.off('ceiling:enter', onCeilingEnter)
|
emitter.off('ceiling:enter', onCeilingEnter)
|
||||||
emitter.off('ceiling:move', onCeilingMove)
|
emitter.off('ceiling:move', onCeilingMove)
|
||||||
emitter.off('ceiling:click', onCeilingClick)
|
emitter.off('ceiling:click', onCeilingClick)
|
||||||
emitter.off('ceiling:leave', onCeilingLeave)
|
emitter.off('ceiling:leave', onCeilingLeave)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling])
|
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (draftItem.current && !isOnWall.current && !isOnCeiling.current) {
|
if (draftNode.current && placementState.current.surface === 'floor') {
|
||||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (draftItemMesh) {
|
if (mesh) {
|
||||||
draftItemMesh.position.lerp(gridPosition.current, delta * 20)
|
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.5,
|
||||||
0.5000000000000003
|
0.5000000000000003
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
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 { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
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
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D
|
||||||
if (!mesh) return
|
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
|
// Wall-attached item: offset Z by half the parent wall's thickness
|
||||||
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||||
if (parentWall && parentWall.type === 'wall') {
|
if (parentWall && parentWall.type === 'wall') {
|
||||||
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
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) {
|
} else if (!item.asset.attachTo) {
|
||||||
// Floor item: elevate by slab height (using full footprint overlap)
|
// Floor item: elevate by slab height (using full footprint overlap)
|
||||||
const levelId = resolveLevelId(item, nodes)
|
const levelId = resolveLevelId(item, nodes)
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
levelId, item.position, item.asset.dimensions, item.rotation,
|
levelId,
|
||||||
|
item.position,
|
||||||
|
item.asset.dimensions,
|
||||||
|
item.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user