move + item corrections

This commit is contained in:
wass08
2026-01-28 10:54:12 +09:00
parent f50f7d105b
commit 82a18de88c
14 changed files with 745 additions and 551 deletions
@@ -46,6 +46,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
structure: {
types: ["wall", "item", "zone", "slab", "ceiling"],
handleSelect: (node, isShift) => {
// Single click on item (door/window) → enter move mode
if (!isShift && node.type === 'item') {
useEditor.getState().setMovingNode(node as ItemNode);
return;
}
const { selection, setSelection } = useViewer.getState();
if (node.type === 'zone') {
setSelection({ zoneId: node.id });
@@ -88,6 +93,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
furnish: {
types: ["item"],
handleSelect: (node, isShift) => {
// Single click on item → enter move mode
if (!isShift && node.type === 'item') {
useEditor.getState().setMovingNode(node as ItemNode);
return;
}
const { selection, setSelection } = useViewer.getState();
const nextIds = isShift
? selection.selectedIds.includes(node.id)
@@ -112,8 +122,11 @@ export const SelectionManager = () => {
const phase = useEditor((s) => s.phase);
const mode = useEditor((s) => s.mode);
const movingNode = useEditor((s) => s.movingNode);
useEffect(() => {
if (mode !== "select") return;
if (movingNode) return;
const strategy = SELECTION_STRATEGIES[phase];
if (!strategy) return;
@@ -158,7 +171,7 @@ export const SelectionManager = () => {
});
emitter.off("grid:click", onGridClick);
};
}, [phase, mode]);
}, [phase, mode, movingNode]);
return <EditorOutlinerSync />;
};
+12 -403
View File
@@ -1,413 +1,22 @@
import {
type CeilingEvent,
emitter,
type GridEvent,
sceneRegistry,
useScene,
useSpatialQuery,
type WallEvent,
} 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'
import {
ceilingStrategy,
checkCanPlace,
floorStrategy,
wallStrategy,
} from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
export const ItemTool: React.FC = () => {
const cursorRef = useRef<Mesh>(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 draftNode = useDraftNode()
useEffect(() => {
if (!selectedItem) return
useScene.temporal.getState().pause()
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
// Reset placement state for new item
placementState.current = { surface: 'floor', wallId: null, ceilingId: null }
// ---- Helpers ----
const getContext = () => ({
asset: selectedItem,
levelId: useViewer.getState().selection.levelId,
draftItem: draftNode.current,
gridPosition: gridPosition.current,
state: { ...placementState.current },
const cursor = usePlacementCoordinator({
asset: selectedItem!,
draftNode,
initDraft: (gridPosition) => {
if (!selectedItem?.attachTo) {
draftNode.create(gridPosition, selectedItem!)
}
},
onCommitted: () => true,
})
const revalidate = (): boolean => {
const placeable = checkCanPlace(getContext(), validators)
;(cursorRef.current.material as MeshStandardMaterial).color.set(
placeable ? 'green' : 'red',
)
return placeable
}
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 a draft from a transition result on the first valid move.
* If placement is invalid at this position, the draft is immediately destroyed
* so no item appears in the scene until the cursor reaches a valid spot.
*/
const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY
draftNode.create(gridPosition.current, selectedItem)
const draft = draftNode.current
if (draft) {
Object.assign(draft, result.nodeUpdate)
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
if (!revalidate()) {
draftNode.destroy()
}
}
// ---- Create initial draft (floor items only) ----
// Wall/ceiling items are created on surface enter to avoid floating items.
if (!selectedItem.attachTo) {
draftNode.create(gridPosition.current, selectedItem)
}
revalidate()
// ---- Floor Handlers ----
const onGridMove = (event: GridEvent) => {
const result = floorStrategy.move(getContext(), event)
if (!result) return
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
revalidate()
}
const onGridClick = (event: GridEvent) => {
const result = floorStrategy.click(getContext(), event, validators)
if (!result) return
draftNode.commit(result.nodeUpdate)
draftNode.create(gridPosition.current, selectedItem)
revalidate()
}
// ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
// Try to create draft immediately if placement is valid
if (!draftNode.current) {
ensureDraft(result)
}
}
const onWallMove = (event: WallEvent) => {
const ctx = getContext()
// If not yet on wall surface (e.g. entered via invalid top face),
// promote this move to an enter when hitting a valid side face.
if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
return
}
// No draft yet (first move after enter) — create at current position if valid
if (!draftNode.current) {
const nodes = useScene.getState().nodes
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = wallStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY
// 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]
}
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)
}
}
}
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)
}
// Re-enter the wall — applyTransition creates the next draft at the correct position
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
const onWallLeave = (event: WallEvent) => {
const result = wallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
// Wall/ceiling items: destroy draft so it doesn't float on the floor
if (selectedItem.attachTo) {
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
} else {
applyTransition(result)
}
}
// ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => {
const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
// Try to create draft immediately if placement is valid
if (!draftNode.current) {
ensureDraft(result)
}
}
const onCeilingMove = (event: CeilingEvent) => {
// No draft yet (first move after enter) — create at current position if valid
if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
revalidate()
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) => {
const result = ceilingStrategy.click(getContext(), event, validators)
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
// Re-enter the ceiling — applyTransition creates the next draft at the correct position
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
const onCeilingLeave = (event: CeilingEvent) => {
const result = ceilingStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
// Wall/ceiling items: destroy draft so it doesn't float on the floor
if (selectedItem.attachTo) {
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
} else {
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)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
draftNode.destroy()
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('wall:enter', onWallEnter)
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, draftNode])
useFrame((_, delta) => {
if (draftNode.current && placementState.current.surface === 'floor') {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
// If distance is large, snap immediately
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
mesh.position.copy(gridPosition.current)
return
}
// Otherwise, lerp smoothly
mesh.position.lerp(gridPosition.current, delta * 20)
}
}
})
return (
<group>
<mesh ref={cursorRef}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshStandardMaterial color="red" wireframe />
</mesh>
</group>
)
if (!selectedItem) return null
return <>{cursor}</>
}
@@ -0,0 +1,47 @@
import useEditor from '@/store/use-editor'
import { Vector3 } from 'three'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
import type { PlacementState } from './placement-types'
function getInitialState(node: { asset: { attachTo?: string }; parentId: string | null }): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId }
}
return { surface: 'floor', wallId: null, ceilingId: null }
}
export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
const draftNode = useDraftNode()
const exitMoveMode = () => {
useEditor.getState().setMovingNode(null)
}
const cursor = usePlacementCoordinator({
asset: movingNode!.asset,
draftNode,
initialState: movingNode ? getInitialState(movingNode) : undefined,
initDraft: (gridPosition) => {
if (!movingNode) return
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
},
onCommitted: () => {
exitMoveMode()
return false
},
onCancel: () => {
draftNode.destroy()
exitMoveMode()
},
})
if (!movingNode) return null
return <>{cursor}</>
}
@@ -5,23 +5,41 @@ import type { Vector3 } from 'three'
import type { Asset } from '../../../../../packages/core/src/schema/nodes/item'
import { stripTransient } from './placement-math'
interface OriginalState {
position: [number, number, number]
rotation: [number, number, number]
side: ItemNode['side']
parentId: string | null
metadata: ItemNode['metadata']
}
export interface DraftNodeHandle {
/** Current draft item, or null */
readonly current: ItemNode | null
/** Whether the current draft was adopted (move mode) vs created (create mode) */
readonly isAdopted: boolean
/** 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: delete draft (paused), resume, create fresh node (tracked), re-pause. */
/** Take ownership of an existing scene node as the draft (for move mode). */
adopt: (node: ItemNode) => void
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
commit: (finalUpdate: Partial<ItemNode>) => string | null
/** Destroy the current draft: delete node (stays paused, no undo entry). */
/** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
destroy: () => void
}
/**
* Hook that manages the lifecycle of a transient (draft) item node.
* Handles temporal pause/resume for undo/redo isolation.
*
* Supports two modes:
* - Create mode (via `create()`): draft is a new transient node. Commit = delete+recreate (undo removes node).
* - Move mode (via `adopt()`): draft is an existing node. Commit = update in place (undo reverts position).
*/
export function useDraftNode(): DraftNodeHandle {
const draftRef = useRef<ItemNode | null>(null)
const adoptedRef = useRef(false)
const originalStateRef = useRef<OriginalState | null>(null)
const create = useCallback((gridPosition: Vector3, asset: Asset): ItemNode | null => {
const currentLevelId = useViewer.getState().selection.levelId
@@ -33,17 +51,77 @@ export function useDraftNode(): DraftNodeHandle {
asset,
metadata: { isTransient: true },
})
console.log('create node', node)
useScene.getState().createNode(node, currentLevelId)
draftRef.current = node
adoptedRef.current = false
originalStateRef.current = null
return node
}, [])
const adopt = useCallback((node: ItemNode): void => {
// Save original state so destroy() can restore it
const meta = (typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata))
? node.metadata as Record<string, unknown>
: {}
originalStateRef.current = {
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
parentId: node.parentId,
metadata: node.metadata,
}
draftRef.current = node
adoptedRef.current = true
// Mark as transient so it renders as a draft
useScene.getState().updateNode(node.id, {
metadata: { ...meta, isTransient: true },
})
}, [])
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
const draft = draftRef.current
if (!draft) return null
if (adoptedRef.current) {
// Move mode: update in place (single undoable action)
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
const original = originalStateRef.current!
// Restore original state while paused — so the undo baseline is clean
useScene.getState().updateNode(draft.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Resume → tracked update (undo reverts to original)
useScene.temporal.getState().resume()
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
})
useScene.temporal.getState().pause()
const id = draft.id
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return id
}
// Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
if (!parentId) return null
@@ -68,14 +146,33 @@ export function useDraftNode(): DraftNodeHandle {
// Re-pause for next draft cycle
useScene.temporal.getState().pause()
adoptedRef.current = false
originalStateRef.current = null
return finalNode.id
}, [])
const destroy = useCallback(() => {
if (draftRef.current) {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
const original = originalStateRef.current
useScene.getState().updateNode(draftRef.current.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
} else {
// Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
}
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
}, [])
return useMemo(
@@ -83,10 +180,14 @@ export function useDraftNode(): DraftNodeHandle {
get current() {
return draftRef.current
},
get isAdopted() {
return adoptedRef.current
},
create,
adopt,
commit,
destroy,
}),
[create, commit, destroy],
[create, adopt, commit, destroy],
)
}
@@ -0,0 +1,443 @@
import {
type CeilingEvent,
emitter,
type GridEvent,
sceneRegistry,
useScene,
useSpatialQuery,
type WallEvent,
} 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 { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
import {
ceilingStrategy,
checkCanPlace,
floorStrategy,
wallStrategy,
} from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
import type { DraftNodeHandle } from './use-draft-node'
import type { Asset } from '../../../../../packages/core/src/schema/nodes/item'
export interface PlacementCoordinatorConfig {
asset: Asset
draftNode: DraftNodeHandle
initDraft: (gridPosition: Vector3) => void
onCommitted: () => boolean
onCancel?: () => void
initialState?: PlacementState
}
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
)
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
const { asset, draftNode } = config
useEffect(() => {
useScene.temporal.getState().pause()
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
// Reset placement state
placementState.current = config.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
}
// ---- Helpers ----
const getContext = () => ({
asset,
levelId: useViewer.getState().selection.levelId,
draftItem: draftNode.current,
gridPosition: gridPosition.current,
state: { ...placementState.current },
})
const revalidate = (): boolean => {
const placeable = checkCanPlace(getContext(), validators)
;(cursorRef.current.material as MeshStandardMaterial).color.set(
placeable ? 'green' : 'red',
)
return placeable
}
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)
// In move mode, skip store updates — only the ref + mesh matter during drag
if (!draftNode.isAdopted) {
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
}
revalidate()
}
const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY
draftNode.create(gridPosition.current, asset)
const draft = draftNode.current
if (draft) {
Object.assign(draft, result.nodeUpdate)
if (!draftNode.isAdopted) {
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
}
if (!revalidate()) {
draftNode.destroy()
}
}
// ---- Init draft ----
config.initDraft(gridPosition.current)
// Sync cursor to the draft mesh's world position (handles floor + wall/ceiling items)
if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
mesh.getWorldPosition(cursorRef.current.position)
} else {
cursorRef.current.position.copy(gridPosition.current)
}
}
revalidate()
// ---- Floor Handlers ----
const onGridMove = (event: GridEvent) => {
const result = floorStrategy.move(getContext(), event)
if (!result) return
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
revalidate()
}
const onGridClick = (event: GridEvent) => {
const result = floorStrategy.click(getContext(), event, validators)
if (!result) return
draftNode.commit(result.nodeUpdate)
if (config.onCommitted()) {
draftNode.create(gridPosition.current, asset)
revalidate()
}
}
// ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
}
}
const onWallMove = (event: WallEvent) => {
const ctx = getContext()
if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
return
}
if (!draftNode.current) {
const nodes = useScene.getState().nodes
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = wallStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY
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]
}
const placeable = revalidate()
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]
}
// In move mode, skip store updates — only the ref + mesh matter during drag
if (!draftNode.isAdopted) {
if (result.nodeUpdate) {
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
}
}
}
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)
}
if (config.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onWallLeave = (event: WallEvent) => {
const result = wallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
} else {
applyTransition(result)
}
}
// ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => {
const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
}
}
const onCeilingMove = (event: CeilingEvent) => {
if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
revalidate()
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) => {
const result = ceilingStrategy.click(getContext(), event, validators)
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (config.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onCeilingLeave = (event: CeilingEvent) => {
const result = ceilingStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
} else {
applyTransition(result)
}
}
// ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => {
// Escape / right-click → cancel
if (event.key === 'Escape' && config.onCancel) {
event.preventDefault()
config.onCancel()
return
}
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]]
// In move mode, skip store update — only the ref + cursor mesh matter during drag
if (!draftNode.isAdopted) {
useScene.getState().updateNode(draft.id, { rotation: draft.rotation })
}
cursorRef.current.rotation.y = newRotationY
revalidate()
}
}
window.addEventListener('keydown', onKeyDown)
// ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => {
if (config.onCancel) {
event.preventDefault()
config.onCancel()
}
}
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
const boxGeometry = new BoxGeometry(
asset.dimensions[0],
asset.dimensions[1],
asset.dimensions[2],
)
boxGeometry.translate(0, asset.dimensions[1] / 2, 0)
cursorRef.current.geometry = boxGeometry
// ---- Subscribe ----
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
draftNode.destroy()
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('wall:enter', onWallEnter)
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)
window.removeEventListener('contextmenu', onContextMenu)
}
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
useFrame((_, delta) => {
if (draftNode.current && placementState.current.surface === 'floor') {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
mesh.position.copy(gridPosition.current)
return
}
mesh.position.lerp(gridPosition.current, delta * 20)
}
}
})
return (
<group>
<mesh ref={cursorRef}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshStandardMaterial color="red" wireframe />
</mesh>
</group>
)
}
@@ -2,6 +2,7 @@ import useEditor, { type Phase, type Tool } from "@/store/use-editor";
import { useViewer } from "@pascal-app/viewer";
import { CeilingTool } from "./ceiling/ceiling-tool";
import { ItemTool } from "./item/item-tool";
import { MoveTool } from "./item/move-tool";
import { SlabTool } from "./slab/slab-tool";
import { WallTool } from "./wall/wall-tool";
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
@@ -25,6 +26,7 @@ export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode);
const tool = useEditor((state) => state.tool);
const movingNode = useEditor((state) => state.movingNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId);
// Show zone boundary editor when in structure/select mode with a zone selected
@@ -39,7 +41,8 @@ export const ToolManager: React.FC = () => {
return (
<>
{showZoneBoundaryEditor && <ZoneBoundaryEditor />}
{BuildToolComponent && <BuildToolComponent />}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</>
);
};
@@ -30,7 +30,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "hydrant",
"category": "furniture",
"category": "appliance",
"name": "hydrant",
"thumbnail": "/items/hydrant/thumbnail.webp",
"src": "/items/hydrant/model.glb",
@@ -58,7 +58,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "sunbed",
"category": "furniture",
"category": "outdoor",
"name": "sunbed",
"thumbnail": "/items/sunbed/thumbnail.webp",
"src": "/items/sunbed/model.glb",
@@ -86,19 +86,19 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "palm",
"category": "furniture",
"category": "outdoor",
"name": "palm",
"thumbnail": "/items/palm/thumbnail.webp",
"src": "/items/palm/model.glb",
"scale": [
0.37,
0.37000000000000005,
0.37,
0.37
],
"offset": [
0.13999999999999999,
0,
-0.20000000000000007
0,
0.02
],
"rotation": [
0,
@@ -106,15 +106,15 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
3.500000000000003,
4.500000000000007,
3.500000000000004
1,
4.5,
1
]
},
{
"id": "fence",
"category": "furniture",
"category": "outdoor",
"name": "fence",
"thumbnail": "/items/fence/thumbnail.webp",
"src": "/items/fence/model.glb",
@@ -142,7 +142,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "patio-umbrella",
"category": "furniture",
"category": "outdoor",
"name": "patio-umbrella",
"thumbnail": "/items/patio-umbrella/thumbnail.webp",
"src": "/items/patio-umbrella/model.glb",
@@ -154,7 +154,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
"offset": [
0,
0,
0.09
0
],
"rotation": [
0,
@@ -162,9 +162,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
3.500000000000003,
3.0000000000000013,
3.500000000000002
1,
3,
1
]
},
@@ -198,7 +198,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "smoke-detector",
"category": "furniture",
"category": "appliance",
"name": "smoke-detector",
"thumbnail": "/items/smoke-detector/thumbnail.webp",
"src": "/items/smoke-detector/model.glb",
@@ -256,7 +256,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "sprinkler",
"category": "furniture",
"category": "appliance",
"name": "sprinkler",
"thumbnail": "/items/sprinkler/thumbnail.webp",
"src": "/items/sprinkler/model.glb",
@@ -314,7 +314,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "ceiling-fan",
"category": "furniture",
"category": "appliance",
"name": "ceiling-fan",
"thumbnail": "/items/ceiling-fan/thumbnail.webp",
"src": "/items/ceiling-fan/model.glb",
@@ -363,9 +363,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
2,
2,
0.4
1.9999999999999991,
2.0000000000000004,
0.39999999999999936
],
"attachTo": "wall"
},
@@ -401,7 +401,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "glass-door",
"category": "furniture",
"category": "door",
"name": "glass-door",
"thumbnail": "/items/glass-door/thumbnail.webp",
"src": "/items/glass-door/model.glb",
@@ -430,7 +430,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "door",
"category": "furniture",
"category": "door",
"name": "door",
"thumbnail": "/items/door/thumbnail.webp",
"src": "/items/door/model.glb",
@@ -459,7 +459,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "thermostat",
"category": "furniture",
"category": "appliance",
"name": "thermostat",
"thumbnail": "/items/thermostat/thumbnail.webp",
"src": "/items/thermostat/model.glb",
@@ -489,7 +489,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "parking-spot",
"category": "furniture",
"category": "outdoor",
"name": "parking-spot",
"thumbnail": "/items/parking-spot/thumbnail.webp",
"src": "/items/parking-spot/model.glb",
@@ -517,7 +517,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "ac-block",
"category": "furniture",
"category": "appliance",
"name": "ac-block",
"thumbnail": "/items/ac-block/thumbnail.webp",
"src": "/items/ac-block/model.glb",
@@ -545,7 +545,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "fire-detector",
"category": "furniture",
"category": "appliance",
"name": "fire-detector",
"thumbnail": "/items/fire-detector/thumbnail.webp",
"src": "/items/fire-detector/model.glb",
@@ -574,7 +574,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "exit-sign",
"category": "furniture",
"category": "appliance",
"name": "exit-sign",
"thumbnail": "/items/exit-sign/thumbnail.webp",
"src": "/items/exit-sign/model.glb",
@@ -594,28 +594,28 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
0.9999999999999999,
1,
0.5,
0.30000000000000004
0.3
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
"id": "electric-panel",
"category": "furniture",
"category": "appliance",
"name": "electric-panel",
"thumbnail": "/items/electric-panel/thumbnail.webp",
"src": "/items/electric-panel/model.glb",
"scale": [
0.6100000000000002,
0.7399999999999999,
0.61,
0.74,
0.7
],
"offset": [
0,
0,
0.060000000000000005
0.06
],
"rotation": [
0,
@@ -623,17 +623,17 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
0.5000000000000001,
0.9999999999999999,
0.30000000000000004
0.5,
1,
0.3
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
"id": "alarm-keypad",
"category": "furniture",
"category": "appliance",
"name": "alarm-keypad",
"thumbnail": "/items/alarm-keypad/thumbnail.webp",
"src": "/items/alarm-keypad/model.glb",
@@ -671,9 +671,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
1
],
"offset": [
-0.08,
0,
0.020000000000000004
0.1,
0.01
],
"rotation": [
0,
@@ -681,10 +681,11 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
0.500000000000001,
0.30000000000000004,
0.5000000000000003
]
1,
0.5,
0.7
],
"attachTo": "wall-side"
},
{
@@ -745,7 +746,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "skate",
"category": "furniture",
"category": "outdoor",
"name": "skate",
"thumbnail": "/items/skate/thumbnail.webp",
"src": "/items/skate/model.glb",
@@ -801,7 +802,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "outdoor-playhouse",
"category": "furniture",
"category": "outdoor",
"name": "outdoor-playhouse",
"thumbnail": "/items/outdoor-playhouse/thumbnail.webp",
"src": "/items/outdoor-playhouse/model.glb",
@@ -829,7 +830,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "scooter",
"category": "furniture",
"category": "outdoor",
"name": "scooter",
"thumbnail": "/items/scooter/thumbnail.webp",
"src": "/items/scooter/model.glb",
@@ -857,7 +858,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "basket-hoop",
"category": "furniture",
"category": "outdoor",
"name": "basket-hoop",
"thumbnail": "/items/basket-hoop/thumbnail.webp",
"src": "/items/basket-hoop/model.glb",
@@ -941,7 +942,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "hood",
"category": "furniture",
"category": "kitchen",
"name": "hood",
"thumbnail": "/items/hood/thumbnail.webp",
"src": "/items/hood/model.glb",
@@ -952,8 +953,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"offset": [
0,
0.5200000000000002,
0.00999999999999999
0.52,
0.01
],
"rotation": [
0,
@@ -961,16 +962,16 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
1.5000000000000016,
1.5,
1,
1.1000000000000003
1.1
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
"id": "kitchen-shelf",
"category": "furniture",
"category": "kitchen",
"name": "kitchen-shelf",
"thumbnail": "/items/kitchen-shelf/thumbnail.webp",
"src": "/items/kitchen-shelf/model.glb",
@@ -981,8 +982,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"offset": [
0,
0.5200000000000002,
0.00999999999999999
0.52,
0.01
],
"rotation": [
0,
@@ -990,11 +991,11 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
2.500000000000002,
2.5,
1,
1.1000000000000003
1.1
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
@@ -1027,7 +1028,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "drying-rack",
"category": "furniture",
"category": "bathroom",
"name": "drying-rack",
"thumbnail": "/items/drying-rack/thumbnail.webp",
"src": "/items/drying-rack/model.glb",
@@ -1066,8 +1067,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"offset": [
0,
0.19000000000000003,
0.11999999999999998
0.19,
0.12
],
"rotation": [
0,
@@ -1075,16 +1076,16 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
0.5000000000000008,
0.5,
0.5000000000000003
0.5,
0.5
],
"attachTo": "wall-side"
},
{
"id": "cutting-board",
"category": "furniture",
"category": "kitchen",
"name": "cutting-board",
"thumbnail": "/items/cutting-board/thumbnail.webp",
"src": "/items/cutting-board/model.glb",
@@ -1112,7 +1113,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "frying-pan",
"category": "furniture",
"category": "kitchen",
"name": "frying-pan",
"thumbnail": "/items/frying-pan/thumbnail.webp",
"src": "/items/frying-pan/model.glb",
@@ -1140,7 +1141,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "kitchen-utensils",
"category": "furniture",
"category": "kitchen",
"name": "kitchen-utensils",
"thumbnail": "/items/kitchen-utensils/thumbnail.webp",
"src": "/items/kitchen-utensils/model.glb",
@@ -1168,7 +1169,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "kitchen-counter",
"category": "furniture",
"category": "kitchen",
"name": "kitchen-counter",
"thumbnail": "/items/kitchen-counter/thumbnail.webp",
"src": "/items/kitchen-counter/model.glb",
@@ -1196,7 +1197,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "kitchen-cabinet",
"category": "furniture",
"category": "kitchen",
"name": "kitchen-cabinet",
"thumbnail": "/items/kitchen-cabinet/thumbnail.webp",
"src": "/items/kitchen-cabinet/model.glb",
@@ -1336,7 +1337,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "television",
"category": "furniture",
"category": "appliance",
"name": "television",
"thumbnail": "/items/television/thumbnail.webp",
"src": "/items/television/model.glb",
@@ -1392,7 +1393,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "air-conditioning",
"category": "furniture",
"category": "appliance",
"name": "air-conditioning",
"thumbnail": "/items/air-conditioning/thumbnail.webp",
"src": "/items/air-conditioning/model.glb",
@@ -1403,8 +1404,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"offset": [
0,
0.37000000000000016,
0.21000000000000005
0.37,
0.21
],
"rotation": [
0,
@@ -1414,14 +1415,14 @@ export const CATALOG_ITEMS: AssetInput[] = [
"dimensions": [
2,
1,
0.8999999999999999
0.9
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
"id": "sewing-machine",
"category": "furniture",
"category": "appliance",
"name": "sewing-machine",
"thumbnail": "/items/sewing-machine/thumbnail.webp",
"src": "/items/sewing-machine/model.glb",
@@ -1533,7 +1534,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "toaster",
"category": "furniture",
"category": "appliance",
"name": "toaster",
"thumbnail": "/items/toaster/thumbnail.webp",
"src": "/items/toaster/model.glb",
@@ -1618,7 +1619,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "kettle",
"category": "furniture",
"category": "appliance",
"name": "kettle",
"thumbnail": "/items/kettle/thumbnail.webp",
"src": "/items/kettle/model.glb",
@@ -1646,7 +1647,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "iron",
"category": "furniture",
"category": "appliance",
"name": "iron",
"thumbnail": "/items/iron/thumbnail.webp",
"src": "/items/iron/model.glb",
@@ -1760,7 +1761,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "coffee-machine",
"category": "furniture",
"category": "appliance",
"name": "coffee-machine",
"thumbnail": "/items/coffee-machine/thumbnail.webp",
"src": "/items/coffee-machine/model.glb",
@@ -1788,7 +1789,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "wine-bottle",
"category": "furniture",
"category": "kitchen",
"name": "wine-bottle",
"thumbnail": "/items/wine-bottle/thumbnail.webp",
"src": "/items/wine-bottle/model.glb",
@@ -1816,7 +1817,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "fruits",
"category": "furniture",
"category": "kitchen",
"name": "fruits",
"thumbnail": "/items/fruits/thumbnail.webp",
"src": "/items/fruits/model.glb",
@@ -1868,7 +1869,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
1,
0.1
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
@@ -1901,7 +1902,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "ball",
"category": "furniture",
"category": "outdoor",
"name": "ball",
"thumbnail": "/items/ball/thumbnail.webp",
"src": "/items/ball/model.glb",
@@ -1950,7 +1951,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
1.5,
1,
0.8,
1
]
},
@@ -2037,12 +2038,12 @@ export const CATALOG_ITEMS: AssetInput[] = [
1,
0.2
],
"attachTo": "wall"
"attachTo": "wall-side"
},
{
"id": "stereo-speaker",
"category": "furniture",
"category": "appliance",
"name": "stereo-speaker",
"thumbnail": "/items/stereo-speaker/thumbnail.webp",
"src": "/items/stereo-speaker/model.glb",
@@ -2098,7 +2099,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "stove",
"category": "furniture",
"category": "kitchen",
"name": "stove",
"thumbnail": "/items/stove/thumbnail.webp",
"src": "/items/stove/model.glb",
@@ -2126,7 +2127,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "fridge",
"category": "furniture",
"category": "kitchen",
"name": "fridge",
"thumbnail": "/items/fridge/thumbnail.webp",
"src": "/items/fridge/model.glb",
@@ -2154,7 +2155,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "kitchen",
"category": "furniture",
"category": "kitchen",
"name": "kitchen",
"thumbnail": "/items/kitchen/thumbnail.webp",
"src": "/items/kitchen/model.glb",
@@ -2182,7 +2183,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "microwave",
"category": "furniture",
"category": "kitchen",
"name": "microwave",
"thumbnail": "/items/microwave/thumbnail.webp",
"src": "/items/microwave/model.glb",
@@ -2322,7 +2323,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "washing-machine",
"category": "furniture",
"category": "bathroom",
"name": "washing-machine",
"thumbnail": "/items/washing-machine/thumbnail.webp",
"src": "/items/washing-machine/model.glb",
@@ -2350,7 +2351,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "laundry-bag",
"category": "furniture",
"category": "bathroom",
"name": "laundry-bag",
"thumbnail": "/items/laundry-bag/thumbnail.webp",
"src": "/items/laundry-bag/model.glb",
@@ -2378,7 +2379,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "shower-angle",
"category": "furniture",
"category": "bathroom",
"name": "shower-angle",
"thumbnail": "/items/shower-angle/thumbnail.webp",
"src": "/items/shower-angle/model.glb",
@@ -2406,7 +2407,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "bathtub",
"category": "furniture",
"category": "bathroom",
"name": "bathtub",
"thumbnail": "/items/bathtub/thumbnail.webp",
"src": "/items/bathtub/model.glb",
@@ -2427,14 +2428,14 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
2.5,
1,
0.8,
1.5
]
},
{
"id": "toilet",
"category": "furniture",
"category": "bathroom",
"name": "toilet",
"thumbnail": "/items/toilet/thumbnail.webp",
"src": "/items/toilet/model.glb",
@@ -2455,7 +2456,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
1,
1,
0.9,
1
]
},
@@ -2483,14 +2484,14 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
2.5,
1,
0.8,
1.5
]
},
{
"id": "shower-square",
"category": "furniture",
"category": "bathroom",
"name": "shower-square",
"thumbnail": "/items/shower-square/thumbnail.webp",
"src": "/items/shower-square/model.glb",
@@ -2518,7 +2519,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "bathroom-sink",
"category": "furniture",
"category": "bathroom",
"name": "bathroom-sink",
"thumbnail": "/items/bathroom-sink/thumbnail.webp",
"src": "/items/bathroom-sink/model.glb",
@@ -2547,7 +2548,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
{
"id": "coffee-table",
"category": "furniture",
"name": "Coffee-table",
"name": "coffee-table",
"thumbnail": "/items/coffee-table/thumbnail.webp",
"src": "/items/coffee-table/model.glb",
"scale": [
@@ -2566,16 +2567,16 @@ export const CATALOG_ITEMS: AssetInput[] = [
0
],
"dimensions": [
2.0,
0.5,
2,
0.4,
1.5
]
},
{
"id": "computer",
"category": "furniture",
"name": "Computer",
"category": "appliance",
"name": "computer",
"thumbnail": "/items/computer/thumbnail.webp",
"src": "/items/computer/model.glb",
"scale": [
@@ -2624,7 +2625,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
1.5,
1,
0.8,
1.5
]
},
@@ -2652,7 +2653,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
1,
1.5,
1.2,
1
]
},
@@ -2708,7 +2709,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
],
"dimensions": [
2.5,
1,
0.8,
1
]
},
@@ -2796,32 +2797,5 @@ export const CATALOG_ITEMS: AssetInput[] = [
0.30000000000000004
],
"attachTo": "wall"
},
{
"id": "couch-medium",
"category": "furniture",
"name": "Couch",
"thumbnail": "/items/couch-medium/thumbnail.webp",
"src": "/items/couch-medium/model.glb",
"scale": [
0.39,
0.39,
0.39
],
"offset": [
0,
0,
0.03
],
"rotation": [
0,
0,
0
],
"dimensions": [
2,
0.8,
1
]
},
}
];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'
import { type BuildingNode, type ItemNode, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import type { Asset } from '../../../packages/core/src/schema/nodes/item'
@@ -56,6 +56,8 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: Asset | null
setSelectedItem: (item: Asset) => void
movingNode: ItemNode | null
setMovingNode: (node: ItemNode | null) => void
}
const useEditor = create<EditorState>()((set, get) => ({
@@ -169,6 +171,8 @@ const useEditor = create<EditorState>()((set, get) => ({
setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
movingNode: null,
setMovingNode: (node) => set({ movingNode: node }),
}))
export default useEditor