From 87029005f694efbf472a7116032eb7d6fa94c6c5 Mon Sep 17 00:00:00 2001 From: wass08 Date: Sat, 17 Jan 2026 11:36:24 +0900 Subject: [PATCH] draft item tool --- apps/editor/components/editor.tsx | 63 ++- .../components/tools/item/item-tool.tsx | 108 +++++ apps/editor/components/tools/tool-manager.tsx | 3 + .../components/tools/wall/wall-tool.tsx | 22 +- packages/core/src/store/use-scene.ts | 379 +++++++++++------- .../renderers/level/level-renderer.tsx | 1 - 6 files changed, 397 insertions(+), 179 deletions(-) create mode 100644 apps/editor/components/tools/item/item-tool.tsx diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx index d2357610..1b699bea 100644 --- a/apps/editor/components/editor.tsx +++ b/apps/editor/components/editor.tsx @@ -51,10 +51,10 @@ export default function Editor() { } const TestUndo = () => { - const {undo, redo, futureStates, pastStates} = useScene.temporal.getState(); + const { undo, redo, futureStates, pastStates } = useScene.temporal.getState(); - - return (
+ return ( +
); -} +}; export const Passes = ({}) => { const { gl: renderer, scene, camera } = useThree(); @@ -169,13 +169,13 @@ const Grid = ({ grid.x .div(fw.x) .add(1 - thickness) - .min(1) + .min(1), ); const lineY = float(1).sub( grid.y .div(fw.y) .add(1 - thickness) - .min(1) + .min(1), ); // Combine both axes - max gives us lines in both directions return lineX.max(lineY); @@ -192,7 +192,7 @@ const Grid = ({ const gridColor = mix( color(cellColor), color(sectionColor), - float(sectionThickness).mul(g2).min(1) + float(sectionThickness).mul(g2).min(1), ); // Combined alpha @@ -229,37 +229,36 @@ const DraftSelector = () => { const selectedItemId = useRef(null); const itemSelectedAt = useRef(0); useEffect(() => { - emitter.on('building:enter', (event) => { - console.log('Entered building:', event.node.id); + emitter.on("building:enter", (event) => { + console.log("Entered building:", event.node.id); const itemMesh = sceneRegistry.nodes.get(event.node.id); selectedObjects.length = 0; selectedObjects.push(itemMesh); }); - emitter.on('building:leave', (event) => { - console.log('Leaving building:', event.node.id); + emitter.on("building:leave", (event) => { + console.log("Leaving building:", event.node.id); selectedObjects.length = 0; }); + // emitter.on("item:click", (event) => { + // event.stopPropagation(); + // if (Date.now() - itemSelectedAt.current < 50) { + // return; + // } + // itemSelectedAt.current = Date.now(); + // if (selectedItemId.current === event.node.id) { + // selectedItemId.current = null; + // console.log("Deselected item:", event.node.id); + // selectedObjects.length = 0; + // return; + // } + // selectedItemId.current = event.node.id; + // const itemMesh = sceneRegistry.nodes.get(event.node.id); + // if (!itemMesh) return; + // selectedObjects.push(itemMesh); - emitter.on("item:click", (event) => { - event.stopPropagation(); - if (Date.now() - itemSelectedAt.current < 50) { - return; - } - itemSelectedAt.current = Date.now(); - if (selectedItemId.current === event.node.id) { - selectedItemId.current = null; - console.log("Deselected item:", event.node.id); - selectedObjects.length = 0; - return; - } - selectedItemId.current = event.node.id; - const itemMesh = sceneRegistry.nodes.get(event.node.id); - if (!itemMesh) return; - selectedObjects.push(itemMesh); - - console.log("Selected item:", event.node.id); - }); + // console.log("Selected item:", event.node.id); + // }); emitter.on("wall:click", (event) => { if (Date.now() - itemSelectedAt.current < 50) { @@ -294,14 +293,14 @@ const DraftSelector = () => { itemMesh.position.set( event.position[0], event.position[1], - event.position[2] + event.position[2], ); useScene.getState().dirtyNodes.add(wallNode.id); console.log( "Wall move event:", wallNode.id, "Point position:", - event.position + event.position, ); }); }, []); diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx new file mode 100644 index 00000000..dc41b6b9 --- /dev/null +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -0,0 +1,108 @@ +import { + emitter, + GridEvent, + ItemNode, + sceneRegistry, + useRegistry, + useScene, + WallNode, +} from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useFrame } from "@react-three/fiber"; +import { use, useEffect, useRef } from "react"; +import { Line, Mesh, Vector3 } from "three"; +import { randInt } from "three/src/math/MathUtils.js"; + +export const ItemTool: React.FC = () => { + const cursorRef = useRef(null); + const draftItem = useRef(null); + const gridPosition = useRef(new Vector3(0, 0, 0)); + + useEffect(() => { + const createDraftItem = () => { + const { currentLevelId } = useViewer.getState(); + if (!currentLevelId) { + return null; + } + useScene.temporal.getState().pause(); + draftItem.current = ItemNode.parse({ + position: [randInt(-10, 10), 0, randInt(-10, 10)], + name: "Draft Item", + asset: { + category: "furniture", + src: "/items/couch-small/model.glb", + dimensions: [2, 1, 3], + }, + }); + useScene.getState().createNode(draftItem.current, currentLevelId); + }; + createDraftItem(); + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return; + + gridPosition.current.set( + Math.round(event.position[0] * 2) / 2, + 0, + Math.round(event.position[1] * 2) / 2, + ); + cursorRef.current.position.set( + gridPosition.current.x, + 0.1, + gridPosition.current.z, + ); + if (draftItem.current) { + draftItem.current.position = [ + gridPosition.current.x, + 0, + gridPosition.current.z, + ]; + } + }; + const onGridClick = (event: GridEvent) => { + const { currentLevelId } = useViewer.getState(); + + console.log("oh", currentLevelId, draftItem.current); + if (!currentLevelId || !draftItem.current) return; + + console.log("oh"); + useScene.temporal.getState().resume(); + useScene.getState().updateNode(draftItem.current.id, { + position: [gridPosition.current.x, 0, gridPosition.current.z], + }); + draftItem.current = null; + + useScene.temporal.getState().pause(); + createDraftItem(); + }; + + emitter.on("grid:move", onGridMove); + emitter.on("grid:click", onGridClick); + + return () => { + if (draftItem.current) { + useScene.getState().deleteNode(draftItem.current.id); + } + emitter.off("grid:move", onGridMove); + emitter.off("grid:click", onGridClick); + }; + }, []); + + useFrame((_, delta) => { + if (draftItem.current) { + const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); + if (draftItemMesh) { + draftItemMesh.position.lerp(gridPosition.current, delta * 10); + } + } + }); + + return ( + + + + + + + ); +}; diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 7d0ed5c3..a13c6e6e 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,13 +1,16 @@ import useEditor, { Phase, Tool } from "@/store/use-editor"; import { WallTool } from "./wall/wall-tool"; +import { ItemTool } from "./item/item-tool"; const tools: Record>> = { site: { }, structure: { wall: WallTool, + item: ItemTool, }, furnish: { + item: ItemTool }, }; diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 264ff50a..608edbd6 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -3,6 +3,18 @@ import { useViewer } from "@pascal-app/viewer"; import { useEffect, useRef } from "react" import { Line, Mesh, Vector3 } from "three"; + +const commitWallDrawing = (start: [number, number], end: [number, number]) => { + const { currentLevelId } = useViewer.getState(); + const { createNode } = useScene.getState(); + + if (!currentLevelId) return; + + const wall = WallNode.parse({ start, end }); + + createNode(wall, currentLevelId); +}; + export const WallTool: React.FC = () => { const cursorRef = useRef(null); @@ -42,15 +54,7 @@ export const WallTool: React.FC = () => { drawingLineRef.current.visible = true; } else if (buildingState === 1) { - const currentLevelId = useViewer.getState().currentLevelId; - console.log('currentLevelId:', currentLevelId); - if (currentLevelId) { - const wallNode = WallNode.parse({ - start: [startingPoint.x, startingPoint.z], - end: [gridPosition[0], gridPosition[1]], - }) - useScene.getState().createNode(wallNode, currentLevelId); - } + commitWallDrawing([startingPoint.x, startingPoint.z], [gridPosition[0], gridPosition[1]]); drawingLineRef.current.visible = false; buildingState = 0; } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index dcf920d4..50ea2efd 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -24,183 +24,288 @@ type SceneState = { createNode: (node: AnyNode, parentId?: AnyNodeId) => void; createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; + + updateNode: (id: AnyNodeId, data: Partial) => void; + updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void; + + deleteNode: (id: AnyNodeId) => void; + deleteNodes: (ids: AnyNodeId[]) => void; }; -type PartializedStoreState = Pick; +// type PartializedStoreState = Pick; -const useScene = create()(temporal((set, get) => ({ - - // 1. Flat dictionary of all nodes - nodes: {}, +const useScene = create()( + temporal( + (set, get) => ({ + // 1. Flat dictionary of all nodes + nodes: {}, - // 2. Root node IDs - rootNodeIds: [], + // 2. Root node IDs + rootNodeIds: [], - // 3. Dirty set - dirtyNodes: new Set(), + // 3. Dirty set + dirtyNodes: new Set(), - loadScene: () => { + loadScene: () => { + const building = BuildingNode.parse({ + children: [], + }); - const building = BuildingNode.parse({ - children: [], - }); + const level0 = LevelNode.parse({ + level: 0, + children: [], + }); + const level1 = LevelNode.parse({ + level: 1, + children: [], + }); + const level2 = LevelNode.parse({ + level: 2, + children: [], + }); + const wall0 = WallNode.parse({ + start: [0, 0], + end: [5, 0], + children: [], + }); - const level0 = LevelNode.parse({ - level: 0, - children: [], - }); - const level1 = LevelNode.parse({ - level: 1, - children: [], - }); - const level2 = LevelNode.parse({ - level: 2, - children: [], - }); + const wall1 = WallNode.parse({ + start: [0, 0], + end: [0, 5], + children: [], + }); - const wall0 = WallNode.parse({ - start: [0, 0], - end: [5, 0], - children: [], - }); + const wall2 = WallNode.parse({ + start: [5, 5], + end: [0, 5], + children: [], + }); - const wall1 = WallNode.parse({ - start: [0, 0], - end: [0, 5], - children: [], - }); + const wall3 = WallNode.parse({ + start: [5, 5], + end: [5, 0], + children: [], + }); - const wall2 = WallNode.parse({ - start: [5, 5], - end: [0, 5], - children: [], - }); + const window1 = ItemNode.parse({ + type: "item", + name: "Window", + position: [2.5, 0.5, 0], + asset: { + category: "windows", + attachTo: "wall", + src: "/items/window-round/model.glb", + }, + }); - const wall3 = WallNode.parse({ - start: [5, 5], - end: [5, 0], - children: [], - }); + wall0.children.push(window1.id); - const window1 = ItemNode.parse({ - type: "item", - name: "Window", - position: [2.5, 0.5, 0], - asset: { - category: "windows", - attachTo: "wall", - src: "/items/window-round/model.glb", + level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id); + + building.children.push(level0.id, level1.id, level2.id); + + // Define all nodes flat + const nodes: Record = { + [building.id]: building, + [level0.id]: level0, + [level1.id]: level1, + [level2.id]: level2, + [wall0.id]: wall0, + [wall1.id]: wall1, + [wall2.id]: wall2, + [wall3.id]: wall3, + [window1.id]: window1, + }; + + // Root nodes are the levels + const rootNodeIds = [building.id]; + + get().dirtyNodes.add(wall0.id); + get().dirtyNodes.add(wall1.id); + get().dirtyNodes.add(wall2.id); + get().dirtyNodes.add(wall3.id); + set({ nodes, rootNodeIds }); }, - }); - wall0.children.push(window1.id); + markDirty: (id) => { + get().dirtyNodes.add(id); + }, - level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id); + clearDirty: (id) => { + get().dirtyNodes.delete(id); + }, - building.children.push(level0.id, level1.id, level2.id); + createNodes: (ops) => { + set((state) => { + const nextNodes = { ...state.nodes }; + const nextRootIds = [...state.rootNodeIds]; - // Define all nodes flat - const nodes: Record = { - [building.id]: building, - [level0.id]: level0, - [level1.id]: level1, - [level2.id]: level2, - [wall0.id]: wall0, - [wall1.id]: wall1, - [wall2.id]: wall2, - [wall3.id]: wall3, - [window1.id]: window1, - }; + for (const { node, parentId } of ops) { + // 1. Assign parentId to the child (Safe because BaseNode has parentId) + const newNode = { + ...node, + parentId: parentId ?? null, + }; - // Root nodes are the levels - const rootNodeIds = [building.id]; + nextNodes[newNode.id] = newNode; - get().dirtyNodes.add(wall0.id); - get().dirtyNodes.add(wall1.id); - get().dirtyNodes.add(wall2.id); - get().dirtyNodes.add(wall3.id); - set({ nodes, rootNodeIds }); - }, + // 2. Update the Parent's children list + if (parentId && nextNodes[parentId]) { + const parent = nextNodes[parentId]; - markDirty: (id) => { - get().dirtyNodes.add(id); - }, + // Type Guard: Check if the parent node is a container that supports children + if ("children" in parent && Array.isArray(parent.children)) { + nextNodes[parentId] = { + ...parent, + // Use Set to prevent duplicate IDs if createNode is called twice + children: Array.from( + new Set([...parent.children, newNode.id]), + ) as any, // We don't verify child types here + }; + } + } else if (!parentId) { + // 3. Handle Root nodes + if (!nextRootIds.includes(newNode.id)) { + nextRootIds.push(newNode.id); + } + } + } - clearDirty: (id) => { - get().dirtyNodes.delete(id); - }, + return { nodes: nextNodes, rootNodeIds: nextRootIds }; + }); - createNodes: (ops) => { - set((state) => { - const nextNodes = { ...state.nodes }; - const nextRootIds = [...state.rootNodeIds]; + // 4. System Sync + ops.forEach(({ node, parentId }) => { + get().markDirty(node.id); + if (parentId) get().markDirty(parentId); + }); + }, - for (const { node, parentId } of ops) { - // 1. Assign parentId to the child (Safe because BaseNode has parentId) - const newNode = { - ...node, - parentId: parentId ?? null - }; - - nextNodes[newNode.id] = newNode; + // 3. The CONVENIENCE (Singular) + createNode: (node, parentId) => get().createNodes([{ node, parentId }]), - // 2. Update the Parent's children list - if (parentId && nextNodes[parentId]) { - const parent = nextNodes[parentId]; + updateNodes: (updates) => { + const parentsToUpdate = new Set(); - // Type Guard: Check if the parent node is a container that supports children - if ("children" in parent && Array.isArray(parent.children)) { - nextNodes[parentId] = { - ...parent, - // Use Set to prevent duplicate IDs if createNode is called twice - children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here - }; - } - } else if (!parentId) { - // 3. Handle Root nodes - if (!nextRootIds.includes(newNode.id)) { - nextRootIds.push(newNode.id); - } - } - } + set((state) => { + const nextNodes = { ...state.nodes }; - return { nodes: nextNodes, rootNodeIds: nextRootIds }; - }); + for (const { id, data } of updates) { + const currentNode = nextNodes[id]; + if (!currentNode) continue; - // 4. System Sync - ops.forEach(({ node, parentId }) => { - get().markDirty(node.id); - if (parentId) get().markDirty(parentId); - }); -}, + // Handle Reparenting Logic + if ( + data.parentId !== undefined && + data.parentId !== currentNode.parentId + ) { + // 1. Remove from old parent + if (currentNode.parentId && nextNodes[currentNode.parentId]) { + const oldParent = nextNodes[ + currentNode.parentId + ] as AnyContainerNode; + nextNodes[oldParent.id] = { + ...oldParent, + children: oldParent.children.filter( + (childId) => childId !== id, + ), + }; + parentsToUpdate.add(oldParent.id); + } - // 3. The CONVENIENCE (Singular) - createNode: (node, parentId) => get().createNodes([{ node, parentId }]), + // 2. Add to new parent + if (data.parentId && nextNodes[data.parentId]) { + const newParent = nextNodes[data.parentId] as AnyContainerNode; + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newParent.children, id])), + }; + parentsToUpdate.add(newParent.id); + } + } - -}), { - partialize: (state) => { - const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history - return { nodes, rootNodeIds} - }, - limit: 50, // Limit to last 50 actions -})); + // Apply the update + nextNodes[id] = { ...nextNodes[id], ...data }; + } + + return { nodes: nextNodes }; + }); + + // Mark dirty + updates.forEach((u) => get().markDirty(u.id)); + parentsToUpdate.forEach((pId) => get().markDirty(pId)); + }, + + updateNode: (id, data) => get().updateNodes([{ id, data }]), + + // --- DELETE --- + + deleteNodes: (ids) => { + const parentsToMarkDirty = new Set(); + + set((state) => { + const nextNodes = { ...state.nodes }; + let nextRootIds = [...state.rootNodeIds]; + + for (const id of ids) { + const node = nextNodes[id]; + if (!node) continue; + + // 1. Remove reference from Parent + if (node.parentId && nextNodes[node.parentId]) { + const parent = nextNodes[node.parentId] as AnyContainerNode; + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((cid) => cid !== id), + }; + parentsToMarkDirty.add(parent.id); + } + } + + // 2. Remove from Root list + nextRootIds = nextRootIds.filter((rid) => rid !== id); + + // 3. Delete the node itself + delete nextNodes[id]; + + // Note: If you want "Recursive Delete" (deleting a level deletes its walls), + // you would call deleteNodes recursively here for node.children. + } + + return { nodes: nextNodes, rootNodeIds: nextRootIds }; + }); + + // Notify systems that the parent has changed (e.g. Wall needs to fill a window hole) + parentsToMarkDirty.forEach((pId) => get().markDirty(pId)); + }, + + deleteNode: (id) => get().deleteNodes([id]), + }), + { + partialize: (state) => { + const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history + return { nodes, rootNodeIds }; + }, + limit: 50, // Limit to last 50 actions + }, + ), +); useScene.getState().loadScene(); export default useScene; - // Subscribe to the temporal store (Undo/Redo events) useScene.temporal.subscribe((state, prevState) => { // Check if we just jumped in time (Undo/Redo) // If the 'nodes' object changed but it wasn't a normal 'set' const currentNodes = useScene.getState().nodes; - + // Trigger a full scene re-validation - Object.values(currentNodes).forEach(node => { - if (node.type === 'wall') { - useScene.getState().markDirty(node.id); + Object.values(currentNodes).forEach((node) => { + if (node.type === "wall") { + useScene.getState().markDirty(node.id); } }); -}); \ No newline at end of file +}); diff --git a/packages/viewer/src/components/renderers/level/level-renderer.tsx b/packages/viewer/src/components/renderers/level/level-renderer.tsx index f6659034..980c7018 100644 --- a/packages/viewer/src/components/renderers/level/level-renderer.tsx +++ b/packages/viewer/src/components/renderers/level/level-renderer.tsx @@ -8,7 +8,6 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => { useRegistry(node.id, node.type, ref); - console.log('rendering level:', node.id, node.children); return ( {/*