camera view

This commit is contained in:
wass08
2026-01-22 08:14:42 +09:00
parent 36d45fe695
commit 1f23792f9c
12 changed files with 239 additions and 39 deletions
@@ -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} />
+1
View File
@@ -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",