375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import {
|
|
AlertCircle,
|
|
Box,
|
|
Copy,
|
|
Crosshair,
|
|
Download,
|
|
ImageIcon,
|
|
Loader2,
|
|
MoreVertical,
|
|
Sparkles,
|
|
Trash2,
|
|
Waypoints,
|
|
} from "lucide-react";
|
|
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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 { toast } from "sonner";
|
|
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;
|
|
}
|
|
|
|
const VIEW_META: Array<{
|
|
id: World3DViewMode;
|
|
label: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
}> = [
|
|
{ id: "splat", label: "W", icon: Sparkles },
|
|
{ id: "image", label: "I", icon: ImageIcon },
|
|
{ id: "mesh", label: "M", icon: Box },
|
|
];
|
|
|
|
function getWorldNodeViewLabel(view: World3DViewMode) {
|
|
switch (view) {
|
|
case "splat":
|
|
return "WORLD";
|
|
case "image":
|
|
return "IMAGE";
|
|
case "mesh":
|
|
return "MESH";
|
|
case "world":
|
|
default:
|
|
return "ONLINE";
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
export function World3DNode({ node, isSelected }: World3DNodeProps) {
|
|
const data = node.data as World3DNodeData;
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
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 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();
|
|
};
|
|
|
|
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="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
|
|
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
|
<span className="text-xs text-muted-foreground">3D 世界生成中...</span>
|
|
</div>
|
|
) : data.status === "failed" ? (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
|
|
<AlertCircle className="h-7 w-7 text-destructive" />
|
|
<span className="text-xs text-destructive">3D 世界生成失败</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<World3DViewer
|
|
className="h-full w-full"
|
|
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
|
|
hotspots={data.hotspots}
|
|
imageUrl={data.imageUrl}
|
|
interactionMode={interactionMode}
|
|
meshUrl={data.meshUrl}
|
|
mode={activeView}
|
|
onPick={handlePick}
|
|
previewImageUrl={data.previewImage}
|
|
semanticsMetadata={data.semanticsMetadata}
|
|
splatUrl={data.splatUrl}
|
|
worldUrl={data.worldUrl}
|
|
/>
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-3 pb-3 pt-12">
|
|
<div className="flex items-end justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium text-white">
|
|
{data.prompt || "3D 世界"}
|
|
</p>
|
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
<Badge className="border-white/10 bg-black/55 text-zinc-200" variant="outline">
|
|
3D 世界
|
|
</Badge>
|
|
{hotspotCount ? (
|
|
<Badge className="border-amber-500/30 bg-amber-500/10 text-amber-200" variant="outline">
|
|
{hotspotCount} 个热点
|
|
</Badge>
|
|
) : null}
|
|
<Badge className="border-white/10 bg-black/55 text-zinc-300" variant="outline">
|
|
{getWorldNodeViewLabel(activeView)}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-zinc-400">
|
|
{worldModelLabel}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
|
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
|
|
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => (
|
|
<Button
|
|
className="h-7 min-w-7 rounded-full px-2 text-[10px]"
|
|
key={view.id}
|
|
onClick={() => handleSetView(view.id)}
|
|
size="sm"
|
|
type="button"
|
|
variant={activeView === view.id ? "secondary" : "ghost"}
|
|
>
|
|
{view.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{activeView === "splat" && data.status === "completed" ? (
|
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
|
|
<Button
|
|
className="h-7 rounded-full px-2"
|
|
onClick={handleToggleFirstPerson}
|
|
size="sm"
|
|
type="button"
|
|
variant={data.firstPerson ? "secondary" : "ghost"}
|
|
>
|
|
<Waypoints className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
className="h-7 rounded-full px-2"
|
|
onClick={handleToggleHotspotMode}
|
|
size="sm"
|
|
type="button"
|
|
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
|
|
>
|
|
<Crosshair className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{isHovered && data.status === "completed" && (
|
|
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button className="h-7 w-7 rounded-full" size="icon" variant="secondary">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{data.meshUrl ? (
|
|
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
|
|
<Box className="mr-2 h-4 w-4" />
|
|
打开 Mesh
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{data.imageUrl ? (
|
|
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
|
|
<ImageIcon className="mr-2 h-4 w-4" />
|
|
打开图像
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem onClick={handleDownload}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
下载当前资产
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleCopy}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
复制链接
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
删除
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|