diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index fb994417..b2094e34 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,45 +1,46 @@ import mitt from "mitt"; import { ItemNode, WallNode } from "../schema"; import { AnyNode } from "../schema/types"; + +// Base event interfaces 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 +export interface NodeEvent { + node: T; + position: [number, number, number]; + normal?: [number, number, number]; stopPropagation: () => void; } -export interface WallEvent extends NodeEvent { - node: WallNode; -} +export type WallEvent = NodeEvent; +export type ItemEvent = NodeEvent; -export interface ItemEvent extends NodeEvent { - node: ItemNode; -} +// Event suffixes - exported for use in hooks +export const eventSuffixes = [ + "click", + "move", + "enter", + "leave", + "pointerdown", + "pointerup", + "context-menu", + "double-click", +] as const; -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 type EventSuffix = (typeof eventSuffixes)[number]; + +type NodeEvents = { + [K in `${T}:${EventSuffix}`]: E; }; + +type GridEvents = { + [K in `grid:${EventSuffix}`]: GridEvent; +}; + +type EditorEvents = GridEvents & + NodeEvents<"wall", WallEvent> & + NodeEvents<"item", ItemEvent>; + export const emitter = mitt(); diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 9a102e12..65e0ae93 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -9,6 +9,7 @@ export const sceneRegistry = { // Categorized lookups: Type -> Set of IDs // Using a Set is faster for adding/deleting than an Array byType: { + building: new Set(), level: new Set(), wall: new Set(), item: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7b02c5d..c5744d8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,8 @@ export { LevelSystem } from "../../viewer/src/systems/level/level-system"; export { WallSystem } from "./systems/wall/wall-system"; // Events -export { emitter } from "./events/bus"; +export { emitter, eventSuffixes } from "./events/bus"; +export type { ItemEvent, WallEvent, NodeEvent, GridEvent, EventSuffix } from "./events/bus"; // Schema export * from "./schema"; diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index 096e226f..1accb4f3 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -6,7 +6,7 @@ import { LevelNode } from "./level"; export const BuildingNode = BaseNode.extend({ id: objectId("building"), type: nodeType("building"), - children: z.array(LevelNode).default([LevelNode.parse({})]), + children: z.array(LevelNode.shape.id).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 4b7e1ef9..8e13ee38 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1,7 +1,7 @@ "use client"; import { create } from "zustand"; -import { ItemNode } from "../schema"; +import { BuildingNode, ItemNode } from "../schema"; import { LevelNode } from "../schema/nodes/level"; import { WallNode } from "../schema/nodes/wall"; import { AnyNode, AnyNodeId } from "../schema/types"; @@ -33,6 +33,12 @@ const useScene = create()((set, get) => ({ dirtyNodes: new Set(), loadScene: () => { + + const building = BuildingNode.parse({ + children: [], + }); + + const level0 = LevelNode.parse({ level: 0, children: [], @@ -85,8 +91,11 @@ const useScene = create()((set, get) => ({ 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, @@ -98,7 +107,7 @@ const useScene = create()((set, get) => ({ }; // Root nodes are the levels - const rootNodeIds = [level0.id, level1.id, level2.id]; + const rootNodeIds = [building.id]; get().dirtyNodes.add(wall0.id); get().dirtyNodes.add(wall1.id); diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/viewer/src/components/renderers/building/building-renderer.tsx new file mode 100644 index 00000000..fdc9a0e3 --- /dev/null +++ b/packages/viewer/src/components/renderers/building/building-renderer.tsx @@ -0,0 +1,21 @@ +import { BuildingNode, LevelNode, useRegistry } from "@pascal-app/core"; +import { useRef } from "react"; +import { Group } from "three"; +import { NodeRenderer } from "../node-renderer"; + +export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { + const ref = useRef(null!); + + useRegistry(node.id, node.type, ref); + return ( + + {/* + + + */} + {node.children.map((childId) => ( + + ))} + + ); +}; diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 50e59a10..4b6659a9 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -4,6 +4,7 @@ import { useGLTF } from "@react-three/drei/core/Gltf"; import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; import { useCallback, useRef } from "react"; import { Group } from "three"; +import { useNodeEvents } from "../../../hooks/use-node-events"; export const ItemRenderer = ({ node }: { node: ItemNode }) => { const ref = useRef(null!); @@ -15,114 +16,7 @@ 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] - ); + const handlers = useNodeEvents(node, "item"); return ( { object={scene} position={node.position} rotation={node.rotation} - onPointerDown={onPointerDown} - onClick={onClick} - onPointerUp={onPointerUp} - onPointerEnter={onPointerEnter} - onPointerLeave={onPointerLeave} - onPointerMove={onPointerMove} + {...handlers} /> ); }; diff --git a/packages/viewer/src/components/renderers/level/level-renderer.tsx b/packages/viewer/src/components/renderers/level/level-renderer.tsx index 1446672b..d89e569c 100644 --- a/packages/viewer/src/components/renderers/level/level-renderer.tsx +++ b/packages/viewer/src/components/renderers/level/level-renderer.tsx @@ -9,10 +9,10 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => { useRegistry(node.id, node.type, ref); return ( - + {/* - + */} {node.children.map((childId) => ( ))} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index e9a2f959..57f80e2e 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -4,6 +4,7 @@ import { AnyNode, useScene } from "@pascal-app/core"; import { ItemRenderer } from "./item/item-renderer"; import { LevelRenderer } from "./level/level-renderer"; import { WallRenderer } from "./wall/wall-renderer"; +import { BuildingRenderer } from "./building/building-renderer"; export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => { const node = useScene((state) => state.nodes[nodeId]); @@ -12,6 +13,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => { return ( <> + {node.type === "building" && } {node.type === "level" && } {node.type === "item" && } {node.type === "wall" && } diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 6cf20fcc..d71df886 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -3,132 +3,21 @@ import { ThreeEvent } from "@react-three/fiber"; import { useCallback, useRef } from "react"; import { Mesh } from "three"; import { NodeRenderer } from "../node-renderer"; +import { useNodeEvents } from "../../../hooks/use-node-events"; export const WallRenderer = ({ node }: { node: WallNode }) => { const ref = useRef(null!); 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] - ); + const handlers = useNodeEvents(node, "wall"); return ( {/* WallSystem will replace this geometry in the next frame */} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts new file mode 100644 index 00000000..c41d83d6 --- /dev/null +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -0,0 +1,52 @@ +import { + emitter, + EventSuffix, + ItemEvent, + ItemNode, + WallEvent, + WallNode, +} from "@pascal-app/core"; +import { ThreeEvent } from "@react-three/fiber"; + +type NodeConfig = { + item: { node: ItemNode; event: ItemEvent }; + wall: { node: WallNode; event: WallEvent }; +}; + +type NodeType = keyof NodeConfig; + +export function useNodeEvents(node: NodeConfig[T]["node"], type: T) { + const emit = (suffix: EventSuffix, e: ThreeEvent) => { + const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; + const payload = { + 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(), + } as NodeConfig[T]["event"]; + + emitter.emit(eventKey, payload); + }; + + return { + onPointerDown: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("pointerdown", e); + }, + onPointerUp: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("pointerup", e); + }, + onClick: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("click", e); + }, + onPointerEnter: (e: ThreeEvent) => emit("enter", e), + onPointerLeave: (e: ThreeEvent) => emit("leave", e), + onPointerMove: (e: ThreeEvent) => emit("move", e), + onDoubleClick: (e: ThreeEvent) => emit("double-click", e), + onContextMenu: (e: ThreeEvent) => emit("context-menu", e), + }; +} \ No newline at end of file diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index b247ea9a..baf31ddd 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,12 +1,14 @@ "use client"; -import { LevelNode } from "@pascal-app/core"; +import { BuildingNode, LevelNode } from "@pascal-app/core"; import { create } from "zustand"; type ViewerState = { levelMode: "stacked" | "exploded" | "solo" | "manual"; setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; + currentBuildingId: BuildingNode["id"] | null; + setCurrentBuildingId: (id: BuildingNode["id"] | null) => void; currentLevelId: LevelNode["id"] | null; setCurrentLevelId: (id: LevelNode["id"] | null) => void; }; @@ -16,6 +18,8 @@ const useViewer = create()((set, get) => ({ setLevelMode: (mode) => set({ levelMode: mode }), currentLevelId: null, setCurrentLevelId: (id) => set({ currentLevelId: id }), + currentBuildingId: null, + setCurrentBuildingId: (id) => set({ currentBuildingId: id }), })); export default useViewer;