camera view
This commit is contained in:
@@ -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<CameraControlsImpl>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const firstLoad = useRef(true)
|
||||
const controls = useRef<CameraControlsImpl>(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 <CameraControls ref={controls} mouseButtons={mouseButtons} />
|
||||
}
|
||||
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 <CameraControls ref={controls} mouseButtons={mouseButtons} />;
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent"
|
||||
onClick={handleAddLevel}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Add new level</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<TreeNodeActions node={node} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-primary-foreground/20"
|
||||
onClick={handleAddLevel}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Add new level</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
|
||||
@@ -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={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||
onClick={toggleVisibility}
|
||||
title={isVisible ? "Hide" : "Show"}
|
||||
>
|
||||
{isVisible ? (
|
||||
<Eye className="w-3 h-3" />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="relative w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
>
|
||||
<Camera className="w-3 h-3" />
|
||||
{hasCamera && (
|
||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-auto p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={handleViewCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
View snapshot
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
onClick={handleCaptureCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{hasCamera ? "Update snapshot" : "Take snapshot"}
|
||||
</button>
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||
onClick={handleClearCamera}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Clear snapshot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function TreeNodeWrapper({
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-7 cursor-pointer group text-sm",
|
||||
"flex items-center h-7 cursor-pointer group/row text-sm",
|
||||
isSelected
|
||||
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
||||
: isHovered
|
||||
@@ -95,7 +95,7 @@ export function TreeNodeWrapper({
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="opacity-0 group-hover:opacity-100 pr-1">
|
||||
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user