diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 1111c1ca..7d2ddc17 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -1,38 +1,51 @@ -'use client' +"use client"; -import { sceneRegistry } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useEffect, useMemo, useRef } from 'react' -import { Vector3 } from 'three' +import { + type CameraControlEvent, + emitter, + sceneRegistry, + useScene, +} from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { CameraControls, CameraControlsImpl } from "@react-three/drei"; +import { useEffect, useMemo, useRef } from "react"; +import { Vector3 } from "three"; -const currentTarget = new Vector3() +const currentTarget = new Vector3(); export const CustomCameraControls = () => { - const controls = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) - const firstLoad = useRef(true) + const controls = useRef(null!); + const currentLevelId = useViewer((state) => state.selection.levelId); + const firstLoad = useRef(true); useEffect(() => { - let targetY = 0 + let targetY = 0; if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) + const levelMesh = sceneRegistry.nodes.get(currentLevelId); if (levelMesh) { - targetY = levelMesh.position.y + targetY = levelMesh.position.y; } } if (firstLoad.current) { - firstLoad.current = false - ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) + firstLoad.current = false; + (controls.current as CameraControlsImpl).setLookAt( + 20, + 20, + 20, + 0, + 0, + 0, + true, + ); } - ;(controls.current as CameraControlsImpl).getTarget(currentTarget) - ;(controls.current as CameraControlsImpl).moveTo( + (controls.current as CameraControlsImpl).getTarget(currentTarget); + (controls.current as CameraControlsImpl).moveTo( currentTarget.x, targetY, currentTarget.z, true, - ) - }, [currentLevelId]) + ); + }, [currentLevelId]); // Configure mouse buttons based on control mode and camera mode const mouseButtons = useMemo(() => { @@ -41,15 +54,61 @@ export const CustomCameraControls = () => { // cameraMode === 'orthographic' // ? CameraControlsImpl.ACTION.ZOOM // : CameraControlsImpl.ACTION.DOLLY - const wheelAction = CameraControlsImpl.ACTION.DOLLY + const wheelAction = CameraControlsImpl.ACTION.DOLLY; return { left: CameraControlsImpl.ACTION.NONE, middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, - } - }, []) + }; + }, []); - return -} + useEffect(() => { + const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return; + + const position = new Vector3(); + const target = new Vector3(); + controls.current.getPosition(position); + controls.current.getTarget(target); + + const state = useScene.getState(); + + state.updateNode(nodeId, { + camera: { + position: [position.x, position.y, position.z], + target: [target.x, target.y, target.z], + mode: useViewer.getState().cameraMode, + }, + }); + }; + const handleNodeView = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return; + + const node = useScene.getState().nodes[nodeId]; + if (!node || !node.camera) return; + const { position, target } = node.camera; + + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ); + }; + + emitter.on("camera-controls:capture", handleNodeCapture); + emitter.on("camera-controls:view", handleNodeView); + + return () => { + emitter.off("camera-controls:capture", handleNodeCapture); + emitter.off("camera-controls:view", handleNodeView); + }; + }, []); + + return ; +}; diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 0c0dd617..18ec0101 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import { Building2, Plus } from "lucide-react"; import { useState } from "react"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; +import { TreeNodeActions } from "./tree-node-actions"; import { Tooltip, TooltipContent, @@ -47,17 +48,20 @@ export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) { isSelected={isSelected} isHovered={isHovered} actions={ - - - - - Add new level - +
+ + + + + + Add new level + +
} > {node.children.map((childId) => ( diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx index 4eafd53b..610363f9 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx @@ -2,6 +2,7 @@ import { ItemNode } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { Box } from "lucide-react"; import { TreeNodeWrapper } from "./tree-node"; +import { TreeNodeActions } from "./tree-node-actions"; interface ItemTreeNodeProps { node: ItemNode; @@ -28,6 +29,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { onClick={handleClick} isSelected={isSelected} isHovered={isHovered} + actions={} /> ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 120b36e8..9b58b9db 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import { Layers } from "lucide-react"; import { useState } from "react"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; +import { TreeNodeActions } from "./tree-node-actions"; interface LevelTreeNodeProps { node: LevelNode; @@ -30,6 +31,7 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { onClick={handleClick} isSelected={isSelected} isHovered={isHovered} + actions={} > {node.children.map((childId) => ( diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx new file mode 100644 index 00000000..52d3cc45 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx @@ -0,0 +1,106 @@ +import { type AnyNode, emitter, useScene } from "@pascal-app/core"; +import { Camera, Eye, EyeOff, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/primitives/popover"; + +interface TreeNodeActionsProps { + node: AnyNode; +} + +export function TreeNodeActions({ node }: TreeNodeActionsProps) { + const [open, setOpen] = useState(false); + const updateNode = useScene((state) => state.updateNode); + const hasCamera = !!node.camera; + const isVisible = node.visible !== false; + + const toggleVisibility = (e: React.MouseEvent) => { + e.stopPropagation(); + updateNode(node.id, { visible: !isVisible }); + }; + + const handleCaptureCamera = (e: React.MouseEvent) => { + e.stopPropagation(); + emitter.emit("camera-controls:capture", { nodeId: node.id }); + setOpen(false); + }; + const handleViewCamera = (e: React.MouseEvent) => { + e.stopPropagation(); + emitter.emit("camera-controls:view", { nodeId: node.id }); + setOpen(false); + }; + + const handleClearCamera = (e: React.MouseEvent) => { + e.stopPropagation(); + updateNode(node.id, { camera: undefined }); + setOpen(false); + }; + + return ( +
+ + + + + + + e.stopPropagation()} + > +
+ {hasCamera && ( + + )} + + {hasCamera && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx index 4ec08d5c..cf8acd80 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -61,7 +61,7 @@ export function TreeNodeWrapper({
{label}
{actions && ( -
+
{actions}
)} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx index ac95d98b..0aa22d36 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx @@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import { Square } from "lucide-react"; import { useState } from "react"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; +import { TreeNodeActions } from "./tree-node-actions"; interface WallTreeNodeProps { node: WallNode; @@ -35,6 +36,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) { onClick={handleClick} isSelected={isSelected} isHovered={isHovered} + actions={} > {node.children.map((childId) => ( diff --git a/apps/editor/package.json b/apps/editor/package.json index 494332cf..a6249dbf 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -14,6 +14,7 @@ "@pascal-app/core": "*", "@pascal-app/viewer": "*", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/bun.lock b/bun.lock index 87fa7f99..e7999def 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@pascal-app/core": "*", "@pascal-app/viewer": "*", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", @@ -317,6 +318,8 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -1109,6 +1112,8 @@ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index b2f1abca..900d1d5c 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -44,9 +44,18 @@ type GridEvents = { [K in `grid:${EventSuffix}`]: GridEvent; }; +export interface CameraControlEvent { + nodeId: AnyNode["id"]; +} + +type CameraControlEvents = { + "camera-controls:view": CameraControlEvent; + "camera-controls:capture": CameraControlEvent; +}; type EditorEvents = GridEvents & NodeEvents<"wall", WallEvent> & NodeEvents<"item", ItemEvent> & - NodeEvents<"building", BuildingEvent>; + NodeEvents<"building", BuildingEvent> & + CameraControlEvents; export const emitter = mitt(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9f4dde9..7abd820d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ // Store export type { + CameraControlEvent, EventSuffix, GridEvent, ItemEvent, @@ -14,7 +15,10 @@ export { sceneRegistry, useRegistry, } from './hooks/scene-registry/scene-registry' -export { initSpatialGridSync, resolveLevelId } from './hooks/spatial-grid/spatial-grid-sync' +export { + initSpatialGridSync, + resolveLevelId, +} from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' // Schema export * from './schema' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c84b086c..ee277c67 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -28,6 +28,9 @@ type ViewerState = { hoveredId: AnyNode["id"] | null; setHoveredId: (id: AnyNode["id"] | null) => void; + cameraMode: "perspective" | "orthographic"; + setCameraMode: (mode: "perspective" | "orthographic") => void; + levelMode: "stacked" | "exploded" | "solo" | "manual"; setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; @@ -43,6 +46,9 @@ const useViewer = create()((set, get) => ({ hoveredId: null, setHoveredId: (id) => set({ hoveredId: id }), + cameraMode: "perspective", + setCameraMode: (mode) => set({ cameraMode: mode }), + levelMode: "stacked", setLevelMode: (mode) => set({ levelMode: mode }),