save site + camera build position

This commit is contained in:
wass08
2026-02-24 14:48:59 +09:00
parent 0863f5e632
commit 81ede241d5
2 changed files with 208 additions and 20 deletions
@@ -44,23 +44,33 @@ export const ViewerCameraControls = () => {
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false) controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
} }
if (!targetNodeId) return
const node = nodes[targetNodeId] let node = targetNodeId ? nodes[targetNodeId] : null;
if (!targetNodeId) {
const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
}
if (!node) return if (!node) return
// Check if node has a saved camera // Check if node has a saved camera
if (node.camera) { if (node.camera) {
const { position, target } = node.camera const { position, target } = node.camera
controls.current.setLookAt( requestAnimationFrame(() => {
position[0], controls.current.setLookAt(
position[1], position[0],
position[2], position[1],
target[0], position[2],
target[1], target[0],
target[2], target[1],
true, target[2],
) true,
)
})
return
}
if (!targetNodeId) {
// No selection and no site - do nothing
return return
} }
@@ -1,4 +1,5 @@
import { import {
type AnyNodeId,
type BuildingNode, type BuildingNode,
emitter, emitter,
LevelNode, LevelNode,
@@ -225,20 +226,122 @@ function PropertyLineSection() {
// SITE PHASE VIEW - Property line + building buttons // SITE PHASE VIEW - Property line + building buttons
// ============================================================================ // ============================================================================
function CameraPopover({
nodeId,
hasCamera,
open,
onOpenChange,
buttonClassName,
}: {
nodeId: AnyNodeId;
hasCamera: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
buttonClassName?: string;
}) {
const updateNode = useScene((state) => state.updateNode);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
className={cn(
"relative w-6 h-6 flex items-center justify-center rounded cursor-pointer",
buttonClassName
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
<Camera className="w-3.5 h-3.5" />
{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={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId });
onOpenChange(false);
}}
>
<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={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId });
onOpenChange(false);
}}
>
<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={(e) => {
e.stopPropagation();
updateNode(nodeId, { camera: undefined });
onOpenChange(false);
}}
>
<Trash2 className="w-3.5 h-3.5" />
Clear snapshot
</button>
)}
</div>
</PopoverContent>
</Popover>
);
}
function SitePhaseView() { function SitePhaseView() {
const nodes = useScene((state) => state.nodes); const nodes = useScene((state) => state.nodes);
const rootNodeIds = useScene((state) => state.rootNodeIds); const rootNodeIds = useScene((state) => state.rootNodeIds);
const updateNode = useScene((state) => state.updateNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId); const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
const [siteCameraOpen, setSiteCameraOpen] = useState(false);
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null);
// Get site node and its building children
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
const buildings = (siteNode?.type === 'site' ? siteNode.children : []) const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
.map((child) => typeof child === 'string' ? nodes[child] : child) .map((child) => {
const id = typeof child === 'string' ? child : child.id;
return nodes[id] as BuildingNode | undefined;
})
.filter((node): node is BuildingNode => node?.type === "building"); .filter((node): node is BuildingNode => node?.type === "building");
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Site row */}
{siteNode && (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
</div>
<CameraPopover
nodeId={siteNode.id as AnyNodeId}
hasCamera={!!siteNode.camera}
open={siteCameraOpen}
onOpenChange={setSiteCameraOpen}
buttonClassName="hover:bg-accent text-muted-foreground"
/>
</div>
)}
<PropertyLineSection /> <PropertyLineSection />
{buildings.length === 0 ? ( {buildings.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground"> <div className="px-3 py-4 text-sm text-muted-foreground">
@@ -247,19 +350,91 @@ function SitePhaseView() {
) : ( ) : (
<div className="flex flex-col gap-1 p-2"> <div className="flex flex-col gap-1 p-2">
{buildings.map((building) => ( {buildings.map((building) => (
<button <div
key={building.id} key={building.id}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer", "group/building flex items-center rounded-md text-sm transition-colors",
selectedBuildingId === building.id selectedBuildingId === building.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-accent/50 hover:bg-accent text-foreground" : "bg-accent/50 hover:bg-accent text-foreground"
)} )}
onClick={() => setSelection({ buildingId: building.id })}
> >
<Building2 className="w-4 h-4 shrink-0" /> <button
<span className="truncate">{building.name || "Building"}</span> className="flex-1 flex items-center gap-2 px-3 py-2 cursor-pointer min-w-0"
</button> onClick={() => setSelection({ buildingId: building.id })}
>
<Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{building.name || "Building"}</span>
</button>
<Popover
open={buildingCameraOpen === building.id}
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
>
<PopoverTrigger asChild>
<button
className={cn(
"relative opacity-0 group-hover/building:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
selectedBuildingId === building.id
? "hover:bg-primary-foreground/20"
: "hover:bg-accent-foreground/10"
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
<Camera className="w-3.5 h-3.5" />
{building.camera && (
<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">
{building.camera && (
<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={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<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={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<Camera className="w-3.5 h-3.5" />
{building.camera ? "Update snapshot" : "Take snapshot"}
</button>
{building.camera && (
<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={(e) => {
e.stopPropagation();
updateNode(building.id, { camera: undefined });
setBuildingCameraOpen(null);
}}
>
<Trash2 className="w-3.5 h-3.5" />
Clear snapshot
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
))} ))}
</div> </div>
)} )}
@@ -280,7 +455,10 @@ function BuildingSelector() {
// Get site node and its building children // Get site node and its building children
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
const buildings = (siteNode?.type === 'site' ? siteNode.children : []) const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
.map((child) => typeof child === 'string' ? nodes[child] : child) .map((child) => {
const id = typeof child === 'string' ? child : child.id;
return nodes[id] as BuildingNode | undefined;
})
.filter((node): node is BuildingNode => node?.type === "building"); .filter((node): node is BuildingNode => node?.type === "building");
const selectedBuilding = selectedBuildingId const selectedBuilding = selectedBuildingId