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",
+5
View File
@@ -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=="],
+10 -1
View File
@@ -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<EditorEvents>();
+5 -1
View File
@@ -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'
+6
View File
@@ -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<ViewerState>()((set, get) => ({
hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
cameraMode: "perspective",
setCameraMode: (mode) => set({ cameraMode: mode }),
levelMode: "stacked",
setLevelMode: (mode) => set({ levelMode: mode }),