diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index b1362dec..1c263a46 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -46,6 +46,11 @@ const SELECTION_STRATEGIES: Record = { 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 = { 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 ; }; diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index 327848ed..f2884345 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -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(null!) - const gridPosition = useRef(new Vector3(0, 0, 0)) - const placementState = useRef({ - 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 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) + const cursor = usePlacementCoordinator({ + asset: selectedItem!, + draftNode, + initDraft: (gridPosition) => { + if (!selectedItem?.attachTo) { + draftNode.create(gridPosition, selectedItem!) } - 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) - } - } + }, + onCommitted: () => true, }) - return ( - - - - - - - ) + if (!selectedItem) return null + return <>{cursor} } diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx new file mode 100644 index 00000000..77634d2e --- /dev/null +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -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} +} diff --git a/apps/editor/components/tools/item/use-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts index 3c5c6579..b8d93338 100644 --- a/apps/editor/components/tools/item/use-draft-node.ts +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -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) => 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(null) + const adoptedRef = useRef(false) + const originalStateRef = useRef(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 + : {} + + 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): 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], ) } diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx new file mode 100644 index 00000000..302391f8 --- /dev/null +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -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(null!) + const gridPosition = useRef(new Vector3(0, 0, 0)) + const placementState = useRef( + 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 ( + + + + + + + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index cb245217..ac884c60 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -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 && } - {BuildToolComponent && } + {movingNode && } + {!movingNode && BuildToolComponent && } ); }; diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index ecf64c96..42eb2a55 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -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 - ] - }, + } ]; diff --git a/apps/editor/public/items/glass-door/model.glb b/apps/editor/public/items/glass-door/model.glb index 22e441f5..5abb96db 100644 Binary files a/apps/editor/public/items/glass-door/model.glb and b/apps/editor/public/items/glass-door/model.glb differ diff --git a/apps/editor/public/items/shower-angle/model.glb b/apps/editor/public/items/shower-angle/model.glb index 9de4fba9..3d14f126 100644 Binary files a/apps/editor/public/items/shower-angle/model.glb and b/apps/editor/public/items/shower-angle/model.glb differ diff --git a/apps/editor/public/items/shower-square/model.glb b/apps/editor/public/items/shower-square/model.glb index 3d14f126..9de4fba9 100644 Binary files a/apps/editor/public/items/shower-square/model.glb and b/apps/editor/public/items/shower-square/model.glb differ diff --git a/apps/editor/public/items/window-large/model.glb b/apps/editor/public/items/window-large/model.glb index f958bf29..a998b1ea 100644 Binary files a/apps/editor/public/items/window-large/model.glb and b/apps/editor/public/items/window-large/model.glb differ diff --git a/apps/editor/public/items/window-rectangle/model.glb b/apps/editor/public/items/window-rectangle/model.glb index 549bda3f..3557e541 100644 Binary files a/apps/editor/public/items/window-rectangle/model.glb and b/apps/editor/public/items/window-rectangle/model.glb differ diff --git a/apps/editor/public/items/window-square/model.glb b/apps/editor/public/items/window-square/model.glb index 08945753..8dc0a2a8 100644 Binary files a/apps/editor/public/items/window-square/model.glb and b/apps/editor/public/items/window-square/model.glb differ diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 036e81c8..0d80d636 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -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()((set, get) => ({ @@ -169,6 +171,8 @@ const useEditor = create()((set, get) => ({ setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), + movingNode: null, + setMovingNode: (node) => set({ movingNode: node }), })) export default useEditor