save site + camera build position
This commit is contained in:
@@ -44,14 +44,19 @@ 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
|
||||||
|
requestAnimationFrame(() => {
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
position[0],
|
position[0],
|
||||||
position[1],
|
position[1],
|
||||||
@@ -61,6 +66,11 @@ export const ViewerCameraControls = () => {
|
|||||||
target[2],
|
target[2],
|
||||||
true,
|
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"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex-1 flex items-center gap-2 px-3 py-2 cursor-pointer min-w-0"
|
||||||
onClick={() => setSelection({ buildingId: building.id })}
|
onClick={() => setSelection({ buildingId: building.id })}
|
||||||
>
|
>
|
||||||
<Building2 className="w-4 h-4 shrink-0" />
|
<Building2 className="w-4 h-4 shrink-0" />
|
||||||
<span className="truncate">{building.name || "Building"}</span>
|
<span className="truncate">{building.name || "Building"}</span>
|
||||||
</button>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user