draft item tool

This commit is contained in:
wass08
2026-01-17 11:36:24 +09:00
parent 3908432225
commit 87029005f6
6 changed files with 397 additions and 179 deletions
+31 -32
View File
@@ -51,10 +51,10 @@ export default function Editor() {
} }
const TestUndo = () => { const TestUndo = () => {
const {undo, redo, futureStates, pastStates} = useScene.temporal.getState(); const { undo, redo, futureStates, pastStates } = useScene.temporal.getState();
return (
return ( <div className="absolute top-4 right-4 z-10 flex gap-2"> <div className="absolute top-4 right-4 z-10 flex gap-2">
<button <button
className="px-4 py-2 rounded bg-white" className="px-4 py-2 rounded bg-white"
onClick={() => { onClick={() => {
@@ -73,7 +73,7 @@ const TestUndo = () => {
</button> </button>
</div> </div>
); );
} };
export const Passes = ({}) => { export const Passes = ({}) => {
const { gl: renderer, scene, camera } = useThree(); const { gl: renderer, scene, camera } = useThree();
@@ -169,13 +169,13 @@ const Grid = ({
grid.x grid.x
.div(fw.x) .div(fw.x)
.add(1 - thickness) .add(1 - thickness)
.min(1) .min(1),
); );
const lineY = float(1).sub( const lineY = float(1).sub(
grid.y grid.y
.div(fw.y) .div(fw.y)
.add(1 - thickness) .add(1 - thickness)
.min(1) .min(1),
); );
// Combine both axes - max gives us lines in both directions // Combine both axes - max gives us lines in both directions
return lineX.max(lineY); return lineX.max(lineY);
@@ -192,7 +192,7 @@ const Grid = ({
const gridColor = mix( const gridColor = mix(
color(cellColor), color(cellColor),
color(sectionColor), color(sectionColor),
float(sectionThickness).mul(g2).min(1) float(sectionThickness).mul(g2).min(1),
); );
// Combined alpha // Combined alpha
@@ -229,37 +229,36 @@ const DraftSelector = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null); const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null);
const itemSelectedAt = useRef<number>(0); const itemSelectedAt = useRef<number>(0);
useEffect(() => { useEffect(() => {
emitter.on('building:enter', (event) => { emitter.on("building:enter", (event) => {
console.log('Entered building:', event.node.id); console.log("Entered building:", event.node.id);
const itemMesh = sceneRegistry.nodes.get(event.node.id); const itemMesh = sceneRegistry.nodes.get(event.node.id);
selectedObjects.length = 0; selectedObjects.length = 0;
selectedObjects.push(itemMesh); selectedObjects.push(itemMesh);
}); });
emitter.on('building:leave', (event) => { emitter.on("building:leave", (event) => {
console.log('Leaving building:', event.node.id); console.log("Leaving building:", event.node.id);
selectedObjects.length = 0; 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) => { // console.log("Selected item:", event.node.id);
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);
});
emitter.on("wall:click", (event) => { emitter.on("wall:click", (event) => {
if (Date.now() - itemSelectedAt.current < 50) { if (Date.now() - itemSelectedAt.current < 50) {
@@ -294,14 +293,14 @@ const DraftSelector = () => {
itemMesh.position.set( itemMesh.position.set(
event.position[0], event.position[0],
event.position[1], event.position[1],
event.position[2] event.position[2],
); );
useScene.getState().dirtyNodes.add(wallNode.id); useScene.getState().dirtyNodes.add(wallNode.id);
console.log( console.log(
"Wall move event:", "Wall move event:",
wallNode.id, wallNode.id,
"Point position:", "Point position:",
event.position event.position,
); );
}); });
}, []); }, []);
@@ -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<Mesh>(null);
const draftItem = useRef<ItemNode | null>(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 (
<group>
<mesh ref={cursorRef}>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="red" />
</mesh>
</group>
);
};
@@ -1,13 +1,16 @@
import useEditor, { Phase, Tool } from "@/store/use-editor"; import useEditor, { Phase, Tool } from "@/store/use-editor";
import { WallTool } from "./wall/wall-tool"; import { WallTool } from "./wall/wall-tool";
import { ItemTool } from "./item/item-tool";
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {
}, },
structure: { structure: {
wall: WallTool, wall: WallTool,
item: ItemTool,
}, },
furnish: { furnish: {
item: ItemTool
}, },
}; };
@@ -3,6 +3,18 @@ import { useViewer } from "@pascal-app/viewer";
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { Line, Mesh, Vector3 } from "three"; 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 = () => { export const WallTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Mesh>(null);
@@ -42,15 +54,7 @@ export const WallTool: React.FC = () => {
drawingLineRef.current.visible = true; drawingLineRef.current.visible = true;
} else if (buildingState === 1) { } else if (buildingState === 1) {
const currentLevelId = useViewer.getState().currentLevelId; commitWallDrawing([startingPoint.x, startingPoint.z], [gridPosition[0], gridPosition[1]]);
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);
}
drawingLineRef.current.visible = false; drawingLineRef.current.visible = false;
buildingState = 0; buildingState = 0;
} }
+119 -14
View File
@@ -24,12 +24,19 @@ type SceneState = {
createNode: (node: AnyNode, parentId?: AnyNodeId) => void; createNode: (node: AnyNode, parentId?: AnyNodeId) => void;
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void;
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void;
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void;
deleteNode: (id: AnyNodeId) => void;
deleteNodes: (ids: AnyNodeId[]) => void;
}; };
type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>; // type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
const useScene = create<SceneState>()(temporal((set, get) => ({
const useScene = create<SceneState>()(
temporal(
(set, get) => ({
// 1. Flat dictionary of all nodes // 1. Flat dictionary of all nodes
nodes: {}, nodes: {},
@@ -40,12 +47,10 @@ const useScene = create<SceneState>()(temporal((set, get) => ({
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
loadScene: () => { loadScene: () => {
const building = BuildingNode.parse({ const building = BuildingNode.parse({
children: [], children: [],
}); });
const level0 = LevelNode.parse({ const level0 = LevelNode.parse({
level: 0, level: 0,
children: [], children: [],
@@ -140,7 +145,7 @@ const useScene = create<SceneState>()(temporal((set, get) => ({
// 1. Assign parentId to the child (Safe because BaseNode has parentId) // 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = { const newNode = {
...node, ...node,
parentId: parentId ?? null parentId: parentId ?? null,
}; };
nextNodes[newNode.id] = newNode; nextNodes[newNode.id] = newNode;
@@ -154,7 +159,9 @@ const useScene = create<SceneState>()(temporal((set, get) => ({
nextNodes[parentId] = { nextNodes[parentId] = {
...parent, ...parent,
// Use Set to prevent duplicate IDs if createNode is called twice // 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 children: Array.from(
new Set([...parent.children, newNode.id]),
) as any, // We don't verify child types here
}; };
} }
} else if (!parentId) { } else if (!parentId) {
@@ -173,24 +180,122 @@ const useScene = create<SceneState>()(temporal((set, get) => ({
get().markDirty(node.id); get().markDirty(node.id);
if (parentId) get().markDirty(parentId); if (parentId) get().markDirty(parentId);
}); });
}, },
// 3. The CONVENIENCE (Singular) // 3. The CONVENIENCE (Singular)
createNode: (node, parentId) => get().createNodes([{ node, parentId }]), createNode: (node, parentId) => get().createNodes([{ node, parentId }]),
updateNodes: (updates) => {
const parentsToUpdate = new Set<string>();
}), { set((state) => {
const nextNodes = { ...state.nodes };
for (const { id, data } of updates) {
const currentNode = nextNodes[id];
if (!currentNode) continue;
// 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);
}
// 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);
}
}
// 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<string>();
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) => { partialize: (state) => {
const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history
return { nodes, rootNodeIds} return { nodes, rootNodeIds };
}, },
limit: 50, // Limit to last 50 actions limit: 50, // Limit to last 50 actions
})); },
),
);
useScene.getState().loadScene(); useScene.getState().loadScene();
export default useScene; export default useScene;
// Subscribe to the temporal store (Undo/Redo events) // Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state, prevState) => { useScene.temporal.subscribe((state, prevState) => {
// Check if we just jumped in time (Undo/Redo) // Check if we just jumped in time (Undo/Redo)
@@ -198,8 +303,8 @@ useScene.temporal.subscribe((state, prevState) => {
const currentNodes = useScene.getState().nodes; const currentNodes = useScene.getState().nodes;
// Trigger a full scene re-validation // Trigger a full scene re-validation
Object.values(currentNodes).forEach(node => { Object.values(currentNodes).forEach((node) => {
if (node.type === 'wall') { if (node.type === "wall") {
useScene.getState().markDirty(node.id); useScene.getState().markDirty(node.id);
} }
}); });
@@ -8,7 +8,6 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
useRegistry(node.id, node.type, ref); useRegistry(node.id, node.type, ref);
console.log('rendering level:', node.id, node.children);
return ( return (
<group ref={ref}> <group ref={ref}>
{/* <mesh receiveShadow> {/* <mesh receiveShadow>