From 96865a8ce7f83b808fc0dafd164997d431556049 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 20 Feb 2026 17:24:52 +0900 Subject: [PATCH] window move tool + duplicate --- .../components/tools/item/move-tool.tsx | 27 +- .../tools/window/move-window-tool.tsx | 348 ++++++++++++++++++ .../components/tools/window/window-math.ts | 98 +++++ .../components/tools/window/window-tool.tsx | 100 +---- .../components/ui/panels/window-panel.tsx | 81 +++- apps/editor/store/use-editor.tsx | 5 +- 6 files changed, 544 insertions(+), 115 deletions(-) create mode 100644 apps/editor/components/tools/window/move-window-tool.tsx create mode 100644 apps/editor/components/tools/window/window-math.ts diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index 2a22802b..8c9018be 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -1,6 +1,8 @@ +import type { ItemNode, WindowNode } from '@pascal-app/core' import { Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' @@ -19,34 +21,35 @@ function getInitialState(node: { return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } } -export const MoveTool: React.FC = () => { - const movingNode = useEditor((state) => state.movingNode) +function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { const draftNode = useDraftNode() - const exitMoveMode = () => { - useEditor.getState().setMovingNode(null) - } - const cursor = usePlacementCoordinator({ - asset: movingNode!.asset, + asset: movingNode.asset, draftNode, - initialState: movingNode ? getInitialState(movingNode) : undefined, + initialState: getInitialState(movingNode), initDraft: (gridPosition) => { - if (!movingNode) return draftNode.adopt(movingNode) gridPosition.copy(new Vector3(...movingNode.position)) }, onCommitted: () => { sfxEmitter.emit('sfx:item-place') - exitMoveMode() + useEditor.getState().setMovingNode(null) return false }, onCancel: () => { draftNode.destroy() - exitMoveMode() + useEditor.getState().setMovingNode(null) }, }) - if (!movingNode) return null return <>{cursor} } + +export const MoveTool: React.FC = () => { + const movingNode = useEditor((state) => state.movingNode) + + if (!movingNode) return null + if (movingNode.type === 'window') return + return +} diff --git a/apps/editor/components/tools/window/move-window-tool.tsx b/apps/editor/components/tools/window/move-window-tool.tsx new file mode 100644 index 00000000..3090c131 --- /dev/null +++ b/apps/editor/components/tools/window/move-window-tool.tsx @@ -0,0 +1,348 @@ +import { + type AnyNodeId, + emitter, + useScene, + type WallEvent, + WindowNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import { BoxGeometry, EdgesGeometry, type Group } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { sfxEmitter } from '@/lib/sfx-bus' +import useEditor from '@/store/use-editor' +import { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, +} from '../item/placement-math' +import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' + +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +/** + * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. + * + * Move mode (metadata.isNew falsy): + * Adopts the existing window, pauses temporal. On commit: restores original state + * (clean undo baseline) then resumes + updateNode (undo reverts to original position). + * On cancel: restores original state. + * + * Duplicate mode (metadata.isNew = true): + * The node is a freshly created transient copy. On commit: deletes transient + resumes + * + createNode (undo removes the new window entirely). On cancel: deletes the node. + */ +export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { + const cursorGroupRef = useRef(null!) + + const exitMoveMode = () => { + useEditor.getState().setMovingNode(null) + } + + useEffect(() => { + useScene.temporal.getState().pause() + + const meta = (typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null) + ? movingWindowNode.metadata as Record + : {} + const isNew = !!meta.isNew + + // Save original state (only used in move mode) + const original = { + position: [...movingWindowNode.position] as [number, number, number], + rotation: [...movingWindowNode.rotation] as [number, number, number], + side: movingWindowNode.side, + parentId: movingWindowNode.parentId, + wallId: movingWindowNode.wallId, + metadata: movingWindowNode.metadata, + } + + if (!isNew) { + // Move mode: mark the existing window as transient so it hides while being repositioned + useScene.getState().updateNode(movingWindowNode.id, { + metadata: { ...meta, isTransient: true }, + }) + } + + let currentWallId: string | null = movingWindowNode.parentId + + const markWallDirty = (wallId: string | null) => { + if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + } + + const hideCursor = () => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + } + + const updateCursor = ( + worldPosition: [number, number, number], + cursorRotationY: number, + valid: boolean, + ) => { + const group = cursorGroupRef.current + if (!group) return + group.visible = true + group.position.set(...worldPosition) + group.rotation.y = cursorRotationY + edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444) + } + + const onWallEnter = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + const prevWallId = currentWallId + currentWallId = event.node.id + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + + if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) + markWallDirty(event.node.id) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + + if (currentWallId !== event.node.id) { + markWallDirty(currentWallId) + currentWallId = event.node.id + } + markWallDirty(event.node.id) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + if (!valid) return + + let placedId: string + + if (isNew) { + // Duplicate mode: delete transient + resume + createNode + // Undo will remove the newly created node entirely + useScene.getState().deleteNode(movingWindowNode.id) + useScene.temporal.getState().resume() + + const node = WindowNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + width: movingWindowNode.width, + height: movingWindowNode.height, + frameThickness: movingWindowNode.frameThickness, + frameDepth: movingWindowNode.frameDepth, + columnRatios: movingWindowNode.columnRatios, + rowRatios: movingWindowNode.rowRatios, + columnDividerThickness: movingWindowNode.columnDividerThickness, + rowDividerThickness: movingWindowNode.rowDividerThickness, + sill: movingWindowNode.sill, + sillDepth: movingWindowNode.sillDepth, + sillThickness: movingWindowNode.sillThickness, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + placedId = node.id + } else { + // Move mode: restore original (clean baseline) + resume + updateNode + // Undo will revert to the original position + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + metadata: {}, + }) + + if (original.parentId && original.parentId !== event.node.id) { + markWallDirty(original.parentId) + } + placedId = movingWindowNode.id + } + + markWallDirty(event.node.id) + useScene.temporal.getState().pause() + + sfxEmitter.emit('sfx:item-place') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onWallLeave = () => { + hideCursor() + if (isNew) return // No original to restore for duplicates + // Move mode: restore to original position while off-wall + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + const onCancel = () => { + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + if (original.parentId) markWallDirty(original.parentId) + } + useScene.temporal.getState().resume() + hideCursor() + exitMoveMode() + } + + emitter.on('wall:enter', onWallEnter) + emitter.on('wall:move', onWallMove) + emitter.on('wall:click', onWallClick) + emitter.on('wall:leave', onWallLeave) + emitter.on('tool:cancel', onCancel) + + return () => { + // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) + const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode | undefined + const currentMeta = current?.metadata as Record | undefined + if (currentMeta?.isTransient) { + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + if (original.parentId) markWallDirty(original.parentId) + } + } + useScene.temporal.getState().resume() + emitter.off('wall:enter', onWallEnter) + emitter.off('wall:move', onWallMove) + emitter.off('wall:click', onWallClick) + emitter.off('wall:leave', onWallLeave) + emitter.off('tool:cancel', onCancel) + } + }, [movingWindowNode]) + + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry( + movingWindowNode.width, + movingWindowNode.height, + movingWindowNode.frameDepth ?? 0.07, + ) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, [movingWindowNode]) + + return ( + + + + ) +} diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts new file mode 100644 index 00000000..9456b92f --- /dev/null +++ b/apps/editor/components/tools/window/window-math.ts @@ -0,0 +1,98 @@ +import { type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core' + +/** + * Converts wall-local (X along wall, Y = height) to world XYZ. + * Wall-local Y maps directly to world Y; X maps along the wall direction. + */ +export function wallLocalToWorld( + wallNode: WallNode, + localX: number, + localY: number, +): [number, number, number] { + const wallAngle = Math.atan2( + wallNode.end[1] - wallNode.start[1], + wallNode.end[0] - wallNode.start[0], + ) + return [ + wallNode.start[0] + localX * Math.cos(wallAngle), + localY, + wallNode.start[1] + localX * Math.sin(wallAngle), + ] +} + +/** + * Clamps window center position so it stays fully within wall bounds. + */ +export function clampToWall( + wallNode: WallNode, + localX: number, + localY: number, + width: number, + height: number, +): { clampedX: number; clampedY: number } { + const dx = wallNode.end[0] - wallNode.start[0] + const dz = wallNode.end[1] - wallNode.start[1] + const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallHeight = wallNode.height ?? 2.5 + + const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) + return { clampedX, clampedY } +} + +/** + * Directly checks the wall's children for bounding-box overlap with a proposed window. + * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). + * The spatial grid only tracks `item` nodes, so windows must be checked this way. + * Reads the wall's latest children from the store (not the event node) to avoid stale data. + */ +export function hasWallChildOverlap( + wallId: string, + clampedX: number, + clampedY: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined + if (!wallNode) return true // Block if wall not found + const halfW = width / 2 + const halfH = height / 2 + const newBottom = clampedY - halfH + const newTop = clampedY + halfH + const newLeft = clampedX - halfW + const newRight = clampedX + halfW + + for (const childId of wallNode.children) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child) continue + + let childLeft: number, childRight: number, childBottom: number, childTop: number + + if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = item.asset.dimensions + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + childTop = item.position[1] + h + } else if (child.type === 'window') { + const win = child as WindowNode + childLeft = win.position[0] - win.width / 2 + childRight = win.position[0] + win.width / 2 + childBottom = win.position[1] - win.height / 2 // windows store center Y + childTop = win.position[1] + win.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} diff --git a/apps/editor/components/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx index 694d0d71..285be100 100644 --- a/apps/editor/components/tools/window/window-tool.tsx +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -1,10 +1,8 @@ import { type AnyNodeId, emitter, - type ItemNode, useScene, type WallEvent, - type WallNode, WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -18,6 +16,7 @@ import { isValidWallSideFace, snapToHalf, } from '../item/placement-math' +import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' // Shared edge material — reuse across renders, just toggle color const edgeMaterial = new LineBasicNodeMaterial({ @@ -27,103 +26,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -/** - * Converts wall-local (X along wall, Y = height) to world XYZ. - * Wall-local Y maps directly to world Y; X maps along the wall direction. - */ -function wallLocalToWorld( - wallNode: WallNode, - localX: number, - localY: number, -): [number, number, number] { - const wallAngle = Math.atan2( - wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0], - ) - return [ - wallNode.start[0] + localX * Math.cos(wallAngle), - localY, - wallNode.start[1] + localX * Math.sin(wallAngle), - ] -} - -/** - * Clamps window center position so it stays fully within wall bounds. - */ -function clampToWall( - wallNode: WallNode, - localX: number, - localY: number, - width: number, - height: number, -): { clampedX: number; clampedY: number } { - const dx = wallNode.end[0] - wallNode.start[0] - const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = wallNode.height ?? 2.5 - - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) - const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) - return { clampedX, clampedY } -} - -/** - * Directly checks the wall's children for bounding-box overlap with a proposed window. - * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). - * The spatial grid only tracks `item` nodes, so windows must be checked this way. - * Reads the wall's latest children from the store (not the event node) to avoid stale data. - */ -function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true // Block if wall not found - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of wallNode.children) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = item.asset.dimensions - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] // items store bottom Y - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 // windows store center Y - childTop = win.position[1] + win.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} - /** * Window tool — places WindowNodes on walls only. * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. diff --git a/apps/editor/components/ui/panels/window-panel.tsx b/apps/editor/components/ui/panels/window-panel.tsx index da51ee5d..d957a530 100644 --- a/apps/editor/components/ui/panels/window-panel.tsx +++ b/apps/editor/components/ui/panels/window-panel.tsx @@ -1,10 +1,12 @@ 'use client' -import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { FlipHorizontal2, X } from 'lucide-react' +import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react' import Image from 'next/image' import { useCallback } from 'react' +import { sfxEmitter } from '@/lib/sfx-bus' +import useEditor from '@/store/use-editor' import { NumberInput } from '@/components/ui/primitives/number-input' import { Switch } from '@/components/ui/primitives/switch' @@ -13,6 +15,8 @@ export function WindowPanel() { const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] const node = selectedId @@ -40,6 +44,49 @@ export function WindowPanel() { }) }, [node, handleUpdate]) + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId || !node) return + sfxEmitter.emit('sfx:item-delete') + deleteNode(selectedId as AnyNode['id']) + if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) + setSelection({ selectedIds: [] }) + }, [selectedId, node, deleteNode, setSelection]) + + const handleDuplicate = useCallback(() => { + if (!node || !node.parentId) return + sfxEmitter.emit('sfx:item-pick') + useScene.temporal.getState().pause() + const duplicate = WindowNode.parse({ + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + side: node.side, + wallId: node.wallId, + parentId: node.parentId, + width: node.width, + height: node.height, + frameThickness: node.frameThickness, + frameDepth: node.frameDepth, + columnRatios: [...node.columnRatios], + rowRatios: [...node.rowRatios], + columnDividerThickness: node.columnDividerThickness, + rowDividerThickness: node.rowDividerThickness, + sill: node.sill, + sillDepth: node.sillDepth, + sillThickness: node.sillThickness, + metadata: { isNew: true }, + }) + useScene.getState().createNode(duplicate, node.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + if (!node || node.type !== 'window' || selectedIds.length !== 1) return null const numCols = node.columnRatios.length @@ -334,6 +381,36 @@ export function WindowPanel() { )} + + {/* Action Buttons */} +
+
+ + + +
+
) } diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 7ded1403..70f90a37 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -7,6 +7,7 @@ import { type LevelNode, type Space, useScene, + type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' @@ -63,8 +64,8 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | null - setMovingNode: (node: ItemNode | null) => void + movingNode: ItemNode | WindowNode | null + setMovingNode: (node: ItemNode | WindowNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode