761 lines
26 KiB
TypeScript
761 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
AlertCircle,
|
|
Box,
|
|
Camera,
|
|
Copy,
|
|
Crosshair,
|
|
Download,
|
|
ImageIcon,
|
|
Loader2,
|
|
MoreVertical,
|
|
Sparkles,
|
|
Trash2,
|
|
Waypoints,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
|
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import useSWR from "swr";
|
|
import {
|
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
|
resolveThreeDModelDisplayName,
|
|
type PublicThreeDModelAliasConfig,
|
|
} from "@/lib/model3d/model-alias-config";
|
|
import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils";
|
|
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
|
|
import {
|
|
getCanvasToolbarItem,
|
|
type CanvasToolbarConfig,
|
|
type CanvasToolbarItemKey,
|
|
} from "@/lib/canvas-toolbar-config";
|
|
import { toast } from "sonner";
|
|
import { imageGenerationService, worldLabsGenerationService } from "@/services/canvas";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
|
|
import { cn, fetcher } from "@/lib/utils";
|
|
|
|
interface World3DNodeProps {
|
|
node: CanvasNode;
|
|
isSelected: boolean;
|
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
|
}
|
|
|
|
const VIEW_META: Array<{
|
|
id: World3DViewMode;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
toolbarKey: CanvasToolbarItemKey;
|
|
}> = [
|
|
{ id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" },
|
|
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
|
|
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
|
|
];
|
|
|
|
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
|
|
const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60;
|
|
|
|
function resolveAvailableViews(data: World3DNodeData) {
|
|
const views: World3DViewMode[] = [];
|
|
if (data.splatUrl) views.push("splat");
|
|
if (data.imageUrl || data.previewImage) views.push("image");
|
|
if (data.meshUrl) views.push("mesh");
|
|
return views;
|
|
}
|
|
|
|
function resolveActiveView(data: World3DNodeData) {
|
|
const availableViews = resolveAvailableViews(data);
|
|
if (data.activeView && availableViews.includes(data.activeView)) {
|
|
return data.activeView;
|
|
}
|
|
return availableViews[0] || "splat";
|
|
}
|
|
|
|
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
|
|
if (activeView === "splat" && data.splatUrl) {
|
|
return { url: data.splatUrl, filename: "world.spz" };
|
|
}
|
|
if (activeView === "mesh" && data.meshUrl) {
|
|
return { url: data.meshUrl, filename: "world.glb" };
|
|
}
|
|
if (data.splatUrl) {
|
|
return { url: data.splatUrl, filename: "world.spz" };
|
|
}
|
|
if (data.meshUrl) {
|
|
return { url: data.meshUrl, filename: "world.glb" };
|
|
}
|
|
if (data.imageUrl) {
|
|
return { url: data.imageUrl, filename: "world.jpg" };
|
|
}
|
|
if (data.worldUrl) {
|
|
return { url: data.worldUrl, filename: "world.url" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
|
|
switch (activeView) {
|
|
case "world":
|
|
return data.splatUrl || data.meshUrl || data.imageUrl || null;
|
|
case "image":
|
|
return data.imageUrl || data.previewImage || data.meshUrl || data.splatUrl || null;
|
|
case "mesh":
|
|
return data.meshUrl || data.splatUrl || data.imageUrl || null;
|
|
case "splat":
|
|
default:
|
|
return data.splatUrl || data.imageUrl || data.meshUrl || null;
|
|
}
|
|
}
|
|
|
|
function readStringValue(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function readRecordValue(value: unknown) {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, any>)
|
|
: {};
|
|
}
|
|
|
|
function clampNumber(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function World3DNode({
|
|
node,
|
|
isSelected,
|
|
canvasToolbarConfig,
|
|
}: World3DNodeProps) {
|
|
const data = node.data as World3DNodeData;
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
|
|
const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>(
|
|
null
|
|
);
|
|
const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false);
|
|
const hydrationAttemptRef = useRef(0);
|
|
const hydrationTaskRef = useRef<string | null>(null);
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
const addNode = useCanvasStore((state) => state.addNode);
|
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
|
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
|
|
const world3dFirstPersonItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dFirstPerson"
|
|
);
|
|
const world3dHotspotItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dHotspot"
|
|
);
|
|
const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore");
|
|
const world3dOpenMeshItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dOpenMesh"
|
|
);
|
|
const world3dOpenImageItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dOpenImage"
|
|
);
|
|
const world3dDownloadItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dDownload"
|
|
);
|
|
const world3dCopyItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dCopy");
|
|
const world3dDeleteItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"world3dDelete"
|
|
);
|
|
const metadata = useMemo(() => readRecordValue(data.metadata), [data.metadata]);
|
|
const metadataAssetPayload = useMemo(
|
|
() => readRecordValue(metadata.assetPayload),
|
|
[metadata.assetPayload]
|
|
);
|
|
const hydrationTaskId = readStringValue(metadata.taskId);
|
|
const hydrationProviderId =
|
|
readStringValue(metadataAssetPayload.providerId) ||
|
|
readStringValue(metadata.providerId);
|
|
const { data: modelAliasConfigResponse } = useSWR<{
|
|
success?: boolean;
|
|
data?: PublicThreeDModelAliasConfig;
|
|
}>("/api/canvas/model-aliases", fetcher);
|
|
|
|
const activeView = useMemo(() => resolveActiveView(data), [data]);
|
|
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
|
|
const publicModelAliasConfig =
|
|
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
|
|
const worldModelLabel = useMemo(
|
|
() =>
|
|
resolveThreeDModelDisplayName(
|
|
data.modelVersion,
|
|
publicModelAliasConfig.worldModels,
|
|
"原生世界节点",
|
|
),
|
|
[data.modelVersion, publicModelAliasConfig.worldModels],
|
|
);
|
|
const primaryUrl = resolvePrimaryUrl(data, activeView);
|
|
const downloadTarget = inferDownloadTarget(data, activeView);
|
|
const hotspotCount = data.hotspots?.length || 0;
|
|
const placeholderSizeScale = useMemo(
|
|
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
|
|
[node.size.height, node.size.width]
|
|
);
|
|
const overlayChromeScale = useMemo(
|
|
() =>
|
|
clampNumber(
|
|
getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48) *
|
|
Math.max(viewportZoom, 0.1),
|
|
0.1,
|
|
2.4
|
|
),
|
|
[node.size.height, node.size.width, viewportZoom]
|
|
);
|
|
const placeholderViewportScale = useMemo(
|
|
() =>
|
|
clampNumber(
|
|
placeholderSizeScale * Math.max(viewportZoom, 0.1),
|
|
0.1,
|
|
2.4
|
|
),
|
|
[placeholderSizeScale, viewportZoom]
|
|
);
|
|
const placeholderCenterStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
transform: `scale(${placeholderViewportScale})`,
|
|
transformOrigin: "center center",
|
|
}),
|
|
[placeholderViewportScale]
|
|
);
|
|
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
transform: `scale(${placeholderViewportScale})`,
|
|
transformOrigin: "top left",
|
|
}),
|
|
[placeholderViewportScale]
|
|
);
|
|
const topLeftControlsStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
transform: `scale(${overlayChromeScale})`,
|
|
transformOrigin: "top left",
|
|
}),
|
|
[overlayChromeScale]
|
|
);
|
|
const topRightControlsStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
transform: `scale(${overlayChromeScale})`,
|
|
transformOrigin: "top right",
|
|
}),
|
|
[overlayChromeScale]
|
|
);
|
|
const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh");
|
|
const showTopLeftControls = data.status === "completed";
|
|
|
|
useEffect(() => {
|
|
if (data.status !== "completed") return;
|
|
if (data.splatUrl || data.meshUrl) return;
|
|
if (!hydrationTaskId) return;
|
|
if (
|
|
readStringValue(metadata.provider) !== "worldlabs-3d" &&
|
|
readStringValue(metadataAssetPayload.provider) !== "worldlabs-3d"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const taskId = hydrationTaskId;
|
|
const providerId = hydrationProviderId;
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
if (hydrationTaskRef.current !== taskId) {
|
|
hydrationTaskRef.current = taskId;
|
|
hydrationAttemptRef.current = 0;
|
|
}
|
|
|
|
const scheduleNextPoll = () => {
|
|
if (cancelled) return;
|
|
if (hydrationAttemptRef.current >= WORLD_ASSET_HYDRATION_MAX_ATTEMPTS) return;
|
|
timer = setTimeout(() => {
|
|
void pollWorldAssets();
|
|
}, WORLD_ASSET_HYDRATION_INTERVAL_MS);
|
|
};
|
|
|
|
async function pollWorldAssets() {
|
|
if (cancelled) return;
|
|
hydrationAttemptRef.current += 1;
|
|
|
|
const result = await worldLabsGenerationService.getTask(taskId, {
|
|
providerId,
|
|
});
|
|
if (cancelled) return;
|
|
if (!result.success || !result.data) {
|
|
scheduleNextPoll();
|
|
return;
|
|
}
|
|
|
|
const assetPayload = readRecordValue(result.data.assetPayload);
|
|
const splatUrl = pickBestWorldLabsSplatUrl(
|
|
assetPayload.spzUrls as Record<string, string> | null | undefined
|
|
);
|
|
const panoUrl = readStringValue(assetPayload.panoUrl);
|
|
const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl);
|
|
const previewImageUrl = readStringValue(result.data.previewImageUrl);
|
|
const meshUrl =
|
|
readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl);
|
|
const imageUrl = panoUrl || thumbnailUrl || previewImageUrl;
|
|
|
|
if (!splatUrl && !meshUrl && !imageUrl) {
|
|
scheduleNextPoll();
|
|
return;
|
|
}
|
|
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl,
|
|
imageUrl: imageUrl || data.imageUrl,
|
|
meshUrl: meshUrl || data.meshUrl,
|
|
splatUrl: splatUrl || data.splatUrl,
|
|
previewImage: previewImageUrl || imageUrl || data.previewImage,
|
|
status: "completed",
|
|
activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView,
|
|
firstPerson: data.firstPerson ?? false,
|
|
semanticsMetadata:
|
|
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
|
|
? (assetPayload.semanticsMetadata as Record<string, any>)
|
|
: data.semanticsMetadata,
|
|
metadata: {
|
|
...metadata,
|
|
assetPayload: {
|
|
...metadataAssetPayload,
|
|
...assetPayload,
|
|
},
|
|
hydratedAt: new Date().toISOString(),
|
|
},
|
|
} as World3DNodeData,
|
|
});
|
|
}
|
|
|
|
void pollWorldAssets();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
}, [
|
|
data,
|
|
hydrationProviderId,
|
|
hydrationTaskId,
|
|
metadata,
|
|
metadataAssetPayload,
|
|
node.id,
|
|
updateNode,
|
|
]);
|
|
|
|
const handleSetView = (view: World3DViewMode) => {
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
activeView: view,
|
|
} as World3DNodeData,
|
|
});
|
|
};
|
|
|
|
const handleToggleFirstPerson = () => {
|
|
const nextFirstPerson = !data.firstPerson;
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
firstPerson: nextFirstPerson,
|
|
} as World3DNodeData,
|
|
});
|
|
if (nextFirstPerson) {
|
|
setInteractionMode("navigate");
|
|
}
|
|
};
|
|
|
|
const handleToggleHotspotMode = () => {
|
|
const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot";
|
|
setInteractionMode(nextMode);
|
|
if (nextMode === "hotspot" && data.firstPerson) {
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
firstPerson: false,
|
|
} as World3DNodeData,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePick = (pick: { point: [number, number, number] }) => {
|
|
if (interactionMode !== "hotspot") return;
|
|
const nextHotspot: World3DHotspot = {
|
|
id: crypto.randomUUID(),
|
|
label: `热点 ${hotspotCount + 1}`,
|
|
position: {
|
|
x: pick.point[0],
|
|
y: pick.point[1],
|
|
z: pick.point[2],
|
|
},
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
hotspots: [...(data.hotspots || []), nextHotspot],
|
|
} as World3DNodeData,
|
|
});
|
|
setInteractionMode("navigate");
|
|
toast.success("已添加世界热点");
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!primaryUrl) {
|
|
toast.error("暂无可复制的世界地址");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(primaryUrl);
|
|
toast.success("已复制世界链接");
|
|
} catch {
|
|
toast.error("复制失败");
|
|
}
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
if (!downloadTarget) {
|
|
toast.error("暂无可下载的世界资产");
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement("a");
|
|
link.href = downloadTarget.url;
|
|
link.download = downloadTarget.filename;
|
|
link.click();
|
|
};
|
|
|
|
const handleViewerCaptureReady = useCallback(
|
|
(capture: (() => Promise<Blob | null>) | null) => {
|
|
setViewerCapture(() => capture);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleCaptureSnapshot = useCallback(async () => {
|
|
if (!viewerCapture) {
|
|
toast.error("当前视图暂不支持拍照");
|
|
return;
|
|
}
|
|
|
|
setIsCapturingSnapshot(true);
|
|
try {
|
|
const blob = await viewerCapture();
|
|
if (!blob) {
|
|
throw new Error("当前世界画面抓取失败");
|
|
}
|
|
|
|
const file = new File([blob], `world-snapshot-${Date.now()}.png`, {
|
|
type: blob.type || "image/png",
|
|
});
|
|
const uploadResult = await imageGenerationService.uploadImage(file);
|
|
if (!uploadResult.success || !uploadResult.data?.url) {
|
|
throw new Error(uploadResult.error || "上传拍照结果失败");
|
|
}
|
|
|
|
const snapshotWidth = Math.max(320, Math.min(640, Math.round(node.size.width * 0.9)));
|
|
const snapshotHeight = Math.max(
|
|
220,
|
|
Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1)))
|
|
);
|
|
|
|
const newNodeId = addNode({
|
|
type: "image",
|
|
position: {
|
|
x: node.position.x + node.size.width + 24,
|
|
y: node.position.y + 12,
|
|
},
|
|
size: { width: snapshotWidth, height: snapshotHeight },
|
|
data: {
|
|
src: uploadResult.data.url,
|
|
assetId: uploadResult.data.assetId,
|
|
title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`,
|
|
prompt: data.prompt || "3D 世界拍照",
|
|
status: "completed",
|
|
originalSrc: uploadResult.data.url,
|
|
metadata: {
|
|
sourceWorldNodeId: node.id,
|
|
sourceWorldView: activeView,
|
|
sourceWorldPrompt: data.prompt || "",
|
|
captureMode: "world3d",
|
|
},
|
|
},
|
|
connections: [node.id],
|
|
});
|
|
selectNode(newNodeId);
|
|
toast.success("已将当前世界视角添加到画布");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "世界拍照失败";
|
|
toast.error(message);
|
|
} finally {
|
|
setIsCapturingSnapshot(false);
|
|
}
|
|
}, [
|
|
activeView,
|
|
addNode,
|
|
data.prompt,
|
|
node.id,
|
|
node.position.x,
|
|
node.position.y,
|
|
node.size.height,
|
|
node.size.width,
|
|
selectNode,
|
|
viewerCapture,
|
|
]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative h-full w-full overflow-hidden rounded-xl border bg-card shadow-sm",
|
|
isSelected && "border-primary"
|
|
)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{data.status === "generating" ? (
|
|
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
|
|
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
|
|
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
</span>
|
|
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
|
|
3D 世界生成中...
|
|
</span>
|
|
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
|
|
{worldModelLabel || "世界资产正在构建中"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : data.status === "failed" ? (
|
|
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
|
|
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
|
|
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
|
|
<AlertCircle className="h-3.5 w-3.5" />
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
|
|
<AlertCircle className="h-5 w-5" />
|
|
</span>
|
|
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
|
|
3D 世界生成失败
|
|
</span>
|
|
<span className="text-xs text-[#b77480] dark:text-red-300/80">
|
|
可以重新选择模型后再试一次
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="absolute inset-0"
|
|
data-canvas-no-drag="true"
|
|
onWheel={(event) => event.stopPropagation()}
|
|
>
|
|
<World3DViewer
|
|
className="h-full w-full"
|
|
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
|
|
hotspots={data.hotspots}
|
|
imageUrl={data.imageUrl}
|
|
interactionMode={interactionMode}
|
|
meshUrl={data.meshUrl}
|
|
minimalFrame
|
|
mode={activeView}
|
|
onPick={handlePick}
|
|
previewImageUrl={data.previewImage}
|
|
semanticsMetadata={data.semanticsMetadata}
|
|
splatUrl={data.splatUrl}
|
|
worldUrl={data.worldUrl}
|
|
onCaptureReady={handleViewerCaptureReady}
|
|
/>
|
|
</div>
|
|
|
|
</>
|
|
)}
|
|
|
|
{showTopLeftControls ? (
|
|
<div
|
|
className="absolute left-3 top-3 z-20 flex items-center gap-1.5"
|
|
style={topLeftControlsStyle}
|
|
>
|
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
|
|
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => {
|
|
const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey);
|
|
return (
|
|
<Button
|
|
className="h-5 min-w-5 rounded-full px-1.5 text-[9px]"
|
|
key={view.id}
|
|
onClick={() => handleSetView(view.id)}
|
|
size="sm"
|
|
title={viewItem.label}
|
|
type="button"
|
|
variant={activeView === view.id ? "secondary" : "ghost"}
|
|
>
|
|
<CanvasToolbarIcon
|
|
active={activeView !== view.id}
|
|
className="h-2.5 w-2.5"
|
|
fallback={view.icon}
|
|
item={viewItem}
|
|
/>
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{activeView === "splat" ? (
|
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
|
|
<Button
|
|
className="h-5 rounded-full px-1.5"
|
|
onClick={handleToggleFirstPerson}
|
|
size="sm"
|
|
title={world3dFirstPersonItem.label}
|
|
type="button"
|
|
variant={data.firstPerson ? "secondary" : "ghost"}
|
|
>
|
|
<CanvasToolbarIcon
|
|
active={!data.firstPerson}
|
|
className="h-2.5 w-2.5"
|
|
fallback={Waypoints}
|
|
item={world3dFirstPersonItem}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
className="h-5 rounded-full px-1.5"
|
|
onClick={handleToggleHotspotMode}
|
|
size="sm"
|
|
title={world3dHotspotItem.label}
|
|
type="button"
|
|
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
|
|
>
|
|
<CanvasToolbarIcon
|
|
active={interactionMode !== "hotspot"}
|
|
className="h-2.5 w-2.5"
|
|
fallback={Crosshair}
|
|
item={world3dHotspotItem}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{captureEnabled ? (
|
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
|
|
<Button
|
|
className="h-5 rounded-full px-1.5"
|
|
disabled={!viewerCapture || isCapturingSnapshot}
|
|
onClick={() => void handleCaptureSnapshot()}
|
|
size="sm"
|
|
title="拍照"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{isCapturingSnapshot ? (
|
|
<Loader2 className="h-2.5 w-2.5 animate-spin text-white" />
|
|
) : (
|
|
<Camera className="h-2.5 w-2.5 text-white" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{isHovered && data.status === "completed" && (
|
|
<div
|
|
className="absolute right-3 top-3 z-20 flex items-center gap-1"
|
|
style={topRightControlsStyle}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="h-7 w-7 rounded-full"
|
|
size="icon"
|
|
title={world3dMoreItem.label}
|
|
variant="secondary"
|
|
>
|
|
<CanvasToolbarIcon
|
|
className="h-4 w-4"
|
|
fallback={MoreVertical}
|
|
item={world3dMoreItem}
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{data.meshUrl ? (
|
|
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Box}
|
|
item={world3dOpenMeshItem}
|
|
/>
|
|
{world3dOpenMeshItem.label}
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{data.imageUrl ? (
|
|
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={ImageIcon}
|
|
item={world3dOpenImageItem}
|
|
/>
|
|
{world3dOpenImageItem.label}
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem onClick={handleDownload}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Download}
|
|
item={world3dDownloadItem}
|
|
/>
|
|
{world3dDownloadItem.label}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleCopy}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Copy}
|
|
item={world3dCopyItem}
|
|
/>
|
|
{world3dCopyItem.label}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Trash2}
|
|
item={world3dDeleteItem}
|
|
/>
|
|
{world3dDeleteItem.label}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|