diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx
index 17043745..ed72a2db 100644
--- a/apps/editor/components/editor.tsx
+++ b/apps/editor/components/editor.tsx
@@ -1,17 +1,87 @@
"use client";
-import { useScene } from "@pascal-app/core";
+import {
+ emitter,
+ ItemNode,
+ sceneRegistry,
+ useScene,
+ WallNode,
+} from "@pascal-app/core";
import { Viewer } from "@pascal-app/viewer";
+import { Stats } from "@react-three/drei";
+import { useEffect, useRef } from "react";
+import { Mesh } from "three";
+import { MeshBasicNodeMaterial } from "three/webgpu";
export default function Editor() {
return (
-
+
+
+
+
);
}
+const Selector = () => {
+ const selectedItemId = useRef(null);
+ const itemSelectedAt = useRef(0);
+ useEffect(() => {
+ emitter.on("item:click", (event) => {
+ 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);
+ return;
+ }
+ selectedItemId.current = event.node.id;
+ const itemMesh = sceneRegistry.nodes.get(event.node.id);
+ if (!itemMesh) return;
+ itemMesh.traverse((child) => {
+ if ((child as Mesh).isMesh) {
+ (child as Mesh).material = new MeshBasicNodeMaterial({
+ color: "yellow",
+ });
+ }
+ });
+
+ console.log("Selected item:", event.node.id);
+ });
+
+ emitter.on("wall:move", (event) => {
+ const wallNode = event.node as WallNode;
+
+ if (wallNode.children.length === 0) return;
+ const itemId = wallNode.children[0];
+ if (!itemId) return;
+ if (selectedItemId.current !== itemId) return;
+ const itemNode = useScene.getState().nodes[itemId];
+ const itemMesh = sceneRegistry.nodes.get(itemId);
+ if (!itemNode || !itemMesh) return;
+
+ itemMesh.position.set(
+ event.position[0],
+ event.position[1],
+ event.position[2]
+ );
+ useScene.getState().dirtyNodes.add(wallNode.id);
+ console.log(
+ "Wall move event:",
+ wallNode.id,
+ "Point position:",
+ event.position
+ );
+ });
+ }, []);
+
+ return null;
+};
+
const LevelModeSwitcher = () => {
const setLevelMode = useScene((state) => state.setLevelMode);
const levelMode = useScene((state) => state.levelMode);
diff --git a/bun.lock b/bun.lock
index 18a2c378..2b419ad8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -42,6 +42,7 @@
"version": "0.0.1",
"dependencies": {
"dedent": "^1.7.1",
+ "mitt": "^3.0.1",
"nanoid": "^5.1.6",
"zod": "^4.3.5",
"zustand": "^5.0.10",
@@ -752,6 +753,8 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
diff --git a/packages/core/package.json b/packages/core/package.json
index 26ca1bbc..73b0a945 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"dedent": "^1.7.1",
+ "mitt": "^3.0.1",
"nanoid": "^5.1.6",
"zod": "^4.3.5",
"zustand": "^5.0.10"
diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts
new file mode 100644
index 00000000..fb994417
--- /dev/null
+++ b/packages/core/src/events/bus.ts
@@ -0,0 +1,45 @@
+import mitt from "mitt";
+import { ItemNode, WallNode } from "../schema";
+import { AnyNode } from "../schema/types";
+export interface GridEvent {
+ position: [number, number];
+}
+
+export interface NodeEvent {
+ node: AnyNode;
+ position: [number, number, number]; // [x, y, z] world coordinates
+ normal?: [number, number, number]; // [x, y, z] normal vector
+ stopPropagation: () => void;
+}
+
+export interface WallEvent extends NodeEvent {
+ node: WallNode;
+}
+
+export interface ItemEvent extends NodeEvent {
+ node: ItemNode;
+}
+
+type EditorEvents = {
+ "grid:click": GridEvent;
+ "grid:rightclick": GridEvent;
+ "grid:move": GridEvent;
+ "grid:double-click": GridEvent;
+ "grid:enter": GridEvent;
+ "grid:leave": GridEvent;
+ "grid:pointerdown": GridEvent;
+ "grid:pointerup": GridEvent;
+ "wall:click": WallEvent;
+ "wall:move": WallEvent;
+ "wall:enter": WallEvent;
+ "wall:leave": WallEvent;
+ "wall:pointerdown": WallEvent;
+ "wall:pointerup": WallEvent;
+ "item:click": ItemEvent;
+ "item:move": ItemEvent;
+ "item:enter": ItemEvent;
+ "item:leave": ItemEvent;
+ "item:pointerdown": ItemEvent;
+ "item:pointerup": ItemEvent;
+};
+export const emitter = mitt();
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index afcf1ddf..fc5dde13 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,11 +2,17 @@
export { default as useScene } from "./store/useScene";
// Hooks
-export { useRegistry } from "./hooks/scene-registry/scene-registry";
+export {
+ sceneRegistry,
+ useRegistry,
+} from "./hooks/scene-registry/scene-registry";
// Systems
export { LevelSystem } from "./systems/level/level-system";
export { WallSystem } from "./systems/wall/wall-system";
+// Events
+export { emitter } from "./events/bus";
+
// Schema
export * from "./schema";
diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx
index 85601b69..e31cdfa5 100644
--- a/packages/core/src/systems/wall/wall-system.tsx
+++ b/packages/core/src/systems/wall/wall-system.tsx
@@ -43,9 +43,9 @@ function updateWallGeometry(wallId: string) {
if (!mesh) return;
const childrenIds = node.children || [];
- const childrenNodes = childrenIds.map(
- (childId) => useScene.getState().nodes[childId]
- );
+ const childrenNodes = childrenIds
+ .map((childId) => useScene.getState().nodes[childId])
+ .filter((n): n is AnyNode => n !== undefined);
// Perform the Extrusion with Holes logic we discussed
const newGeo = generateExtrudedWall(node, childrenNodes);
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index bdfdfccc..50e59a10 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -1,7 +1,8 @@
-import { ItemNode, useRegistry } from "@pascal-app/core";
+import { emitter, ItemNode, useRegistry } from "@pascal-app/core";
import { Clone } from "@react-three/drei/core/Clone";
import { useGLTF } from "@react-three/drei/core/Gltf";
-import { useRef } from "react";
+import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events";
+import { useCallback, useRef } from "react";
import { Group } from "three";
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
@@ -14,12 +15,127 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
nodes.cutout.visible = false;
}
+ // Event handlers
+
+ const onPointerDown = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ const eventData = {
+ node,
+ position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ };
+ emitter.emit("item:pointerdown", eventData);
+ },
+ [node]
+ );
+
+ const onClick = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ const eventData = {
+ node,
+ position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ };
+ emitter.emit("item:click", eventData);
+ },
+ [node]
+ );
+
+ const onPointerUp = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ emitter.emit("item:pointerup", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerEnter = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("item:enter", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerLeave = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("item:leave", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerMove = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("item:move", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
return (
);
};
diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
index e10ca3e7..6cf20fcc 100644
--- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
+++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
@@ -1,5 +1,6 @@
-import { useRegistry, WallNode } from "@pascal-app/core";
-import { useRef } from "react";
+import { emitter, useRegistry, WallNode } from "@pascal-app/core";
+import { ThreeEvent } from "@react-three/fiber";
+import { useCallback, useRef } from "react";
import { Mesh } from "three";
import { NodeRenderer } from "../node-renderer";
@@ -8,8 +9,127 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
useRegistry(node.id, "wall", ref);
+ // Event handlers
+
+ const onPointerDown = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ const eventData = {
+ node,
+ position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ };
+ emitter.emit("wall:pointerdown", eventData);
+ },
+ [node]
+ );
+
+ const onClick = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ const eventData = {
+ node,
+ position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ };
+ emitter.emit("wall:click", eventData);
+ },
+ [node]
+ );
+
+ const onPointerUp = useCallback(
+ (e: ThreeEvent) => {
+ // Only emit events for left-click (button 0)
+ if (e.button !== 0) return;
+
+ emitter.emit("wall:pointerup", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
+ number,
+ number,
+ number
+ ])
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerEnter = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("wall:enter", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerLeave = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("wall:leave", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
+ const onPointerMove = useCallback(
+ (e: ThreeEvent) => {
+ emitter.emit("wall:move", {
+ node,
+ position: [e.point.x, e.point.y, e.point.z],
+ normal: e.face
+ ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
+ : undefined,
+ stopPropagation: () => e.stopPropagation(),
+ });
+ },
+ [node]
+ );
+
return (
-
+
{/* WallSystem will replace this geometry in the next frame */}
diff --git a/packages/viewer/src/components/viewer/viewer.tsx b/packages/viewer/src/components/viewer/viewer.tsx
index b6cb73c5..df4dfb78 100644
--- a/packages/viewer/src/components/viewer/viewer.tsx
+++ b/packages/viewer/src/components/viewer/viewer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Environment, OrbitControls } from "@react-three/drei";
+import { Bvh, Environment, OrbitControls } from "@react-three/drei";
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
import { LevelSystem, WallSystem } from "@pascal-app/core";
@@ -14,9 +14,11 @@ declare module "@react-three/fiber" {
extend(THREE as any);
-interface ViewerProps {}
+interface ViewerProps {
+ children?: React.ReactNode;
+}
-const Viewer: React.FC = () => {
+const Viewer: React.FC = ({ children }) => {
return (
);
};