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 (
{/*