diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx
index 929023d5..d2357610 100644
--- a/apps/editor/components/editor.tsx
+++ b/apps/editor/components/editor.tsx
@@ -37,6 +37,7 @@ export default function Editor() {
{/*
*/}
+
@@ -49,6 +50,31 @@ export default function Editor() {
);
}
+const TestUndo = () => {
+ const {undo, redo, futureStates, pastStates} = useScene.temporal.getState();
+
+
+ return (
+
+
+
+ );
+}
+
export const Passes = ({}) => {
const { gl: renderer, scene, camera } = useThree();
const postProcessingRef = useRef(null);
diff --git a/bun.lock b/bun.lock
index 8f942691..03edc3e4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -56,6 +56,7 @@
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"zod": "^4.3.5",
+ "zundo": "^2.3.0",
},
"devDependencies": {
"@repo/typescript-config": "*",
@@ -1072,6 +1073,8 @@
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+ "zundo": ["zundo@2.3.0", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="],
+
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
diff --git a/packages/core/package.json b/packages/core/package.json
index f1a09c67..b5d7bceb 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -25,6 +25,7 @@
"dedent": "^1.7.1",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
- "zod": "^4.3.5"
+ "zod": "^4.3.5",
+ "zundo": "^2.3.0"
}
}
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index 3b5c7120..dcf920d4 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -5,6 +5,7 @@ import { BuildingNode, ItemNode } from "../schema";
import { LevelNode } from "../schema/nodes/level";
import { WallNode } from "../schema/nodes/wall";
import { AnyNode, AnyNodeId } from "../schema/types";
+import { temporal } from "zundo";
type SceneState = {
// 1. The Data: A flat dictionary of all nodes
@@ -22,9 +23,13 @@ type SceneState = {
clearDirty: (id: AnyNodeId) => void;
createNode: (node: AnyNode, parentId?: AnyNodeId) => void;
+ createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void;
};
-const useScene = create()((set, get) => ({
+type PartializedStoreState = Pick;
+
+const useScene = create()(temporal((set, get) => ({
+
// 1. Flat dictionary of all nodes
nodes: {},
@@ -126,27 +131,76 @@ const useScene = create()((set, get) => ({
get().dirtyNodes.delete(id);
},
- createNode: (node, parentId) => {
- set((state) => {
- const newNodes = { ...state.nodes, [node.id]: node };
+ createNodes: (ops) => {
+ set((state) => {
+ const nextNodes = { ...state.nodes };
+ const nextRootIds = [...state.rootNodeIds];
- if (parentId) {
- const parent = state.nodes[parentId];
- if (parent) {
- newNodes[parentId] = {
- ...parent,
- children: [...parent.children, node.id],
- };
+ 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;
+
+ // 2. Update the Parent's children list
+ if (parentId && nextNodes[parentId]) {
+ const parent = nextNodes[parentId];
+
+ // 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);
+ }
}
}
- return { nodes: newNodes };
+ return { nodes: nextNodes, rootNodeIds: nextRootIds };
});
- get().markDirty(node.id);
- if (parentId) get().markDirty(parentId);
- }
+ // 4. System Sync
+ ops.forEach(({ node, parentId }) => {
+ get().markDirty(node.id);
+ if (parentId) get().markDirty(parentId);
+ });
+},
+
+ // 3. The CONVENIENCE (Singular)
+ createNode: (node, parentId) => get().createNodes([{ node, parentId }]),
+
+
+}), {
+ 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);
+ }
+ });
+});
\ No newline at end of file