feat: add native world3d canvas workflow
This commit is contained in:
parent
670a50c4d0
commit
67602b0af7
@ -51,6 +51,7 @@ import {
|
||||
Model3DViewer,
|
||||
type Model3DViewerSettings,
|
||||
} from "@/components/canvas/model3d/Model3DViewer";
|
||||
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -99,6 +100,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
type GenerationMode = "text" | "image" | "multiview" | "batch";
|
||||
type WorkspaceMode = "model" | "world";
|
||||
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
|
||||
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
|
||||
type TaskProvider = "tripo-3d" | "worldlabs-3d";
|
||||
type TaskMode =
|
||||
| GenerationMode
|
||||
@ -576,6 +578,8 @@ export default function CanvasModel3DPage() {
|
||||
const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [worldViewportMode, setWorldViewportMode] =
|
||||
useState<WorldViewportMode>("world");
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
@ -1538,8 +1542,33 @@ export default function CanvasModel3DPage() {
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentModelUrl || !currentTask || currentTask.status !== "success") {
|
||||
toast.error("暂无可导入的 3D 模型");
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
toast.error("暂无可导入的世界资产");
|
||||
return;
|
||||
}
|
||||
|
||||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||||
const primarySplatUrl = isWorldTask
|
||||
? Object.values(worldAssetPayload?.spzUrls || {}).find(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0
|
||||
) || null
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
null;
|
||||
|
||||
const canImportWorld = Boolean(
|
||||
currentModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||||
toast.error(isWorldTask ? "暂无可导入的 3D 世界" : "暂无可导入的 3D 模型");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1555,38 +1584,81 @@ export default function CanvasModel3DPage() {
|
||||
const y =
|
||||
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
|
||||
|
||||
addNode({
|
||||
type: "model3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
addNode(
|
||||
isWorldTask
|
||||
? {
|
||||
type: "world3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
currentTask.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
: worldAssetPayload?.worldUrl
|
||||
? "world"
|
||||
: worldImageUrl
|
||||
? "image"
|
||||
: "mesh",
|
||||
firstPerson: false,
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
: {
|
||||
type: "model3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl!,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await canvasService.saveCanvasState(id, getCanvasState());
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 保存画布失败:", error);
|
||||
}
|
||||
toast.success("3D 模型已导入画布");
|
||||
toast.success(isWorldTask ? "3D 世界已导入画布" : "3D 模型已导入画布");
|
||||
};
|
||||
|
||||
const handleDownloadCurrent = () => {
|
||||
@ -1612,12 +1684,15 @@ export default function CanvasModel3DPage() {
|
||||
};
|
||||
|
||||
const handleOpenPanoUrl = () => {
|
||||
const panoUrl = currentTask?.assetPayload?.panoUrl;
|
||||
if (!panoUrl) {
|
||||
toast.error("当前任务暂无可打开的全景图");
|
||||
const imageUrl =
|
||||
currentTask?.assetPayload?.panoUrl ||
|
||||
currentTask?.assetPayload?.thumbnailUrl ||
|
||||
currentPreviewImage;
|
||||
if (!imageUrl) {
|
||||
toast.error("当前任务暂无可打开的图像预览");
|
||||
return;
|
||||
}
|
||||
window.open(panoUrl, "_blank", "noopener,noreferrer");
|
||||
window.open(imageUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleConvertAndDownload = async () => {
|
||||
@ -1907,25 +1982,86 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const currentWorldAsset =
|
||||
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
|
||||
const currentWorldId =
|
||||
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
|
||||
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
|
||||
const currentPrimarySpzUrl =
|
||||
currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null;
|
||||
const currentWorldImageUrl =
|
||||
currentWorldAsset?.panoUrl ||
|
||||
currentWorldAsset?.thumbnailUrl ||
|
||||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||||
null;
|
||||
const importDisabled =
|
||||
!currentTask || currentTask.status !== "success" || !currentModelUrl;
|
||||
!currentTask ||
|
||||
currentTask.status !== "success" ||
|
||||
(currentTask.provider === "worldlabs-3d"
|
||||
? !Boolean(
|
||||
currentModelUrl ||
|
||||
currentWorldAsset?.worldUrl ||
|
||||
currentWorldImageUrl ||
|
||||
currentPrimarySpzUrl
|
||||
)
|
||||
: !currentModelUrl);
|
||||
const activeStatusLabel = currentTask
|
||||
? statusLabel(currentTask.status)
|
||||
: "未开始";
|
||||
const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务";
|
||||
const activePromptSummary =
|
||||
currentTask?.prompt?.trim() ||
|
||||
(workspaceMode === "world" ? "尚未创建世界任务" : "尚未创建模型任务");
|
||||
const activeProviderLabel =
|
||||
currentTask?.provider === "worldlabs-3d" ? "World Labs" : "Tripo3D";
|
||||
currentTask?.provider === "worldlabs-3d" || workspaceMode === "world"
|
||||
? "World Labs"
|
||||
: "Tripo3D";
|
||||
const activeRelativeTime = currentTask
|
||||
? formatDistanceToNow(new Date(currentTask.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})
|
||||
: null;
|
||||
const currentWorldAsset =
|
||||
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
|
||||
const currentWorldId =
|
||||
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
|
||||
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
|
||||
const availableWorldViewportModes = useMemo(() => {
|
||||
const modes: WorldViewportMode[] = [];
|
||||
if (currentPrimarySpzUrl) modes.push("splat");
|
||||
if (currentWorldAsset?.worldUrl) modes.push("world");
|
||||
if (currentWorldImageUrl) modes.push("imagery");
|
||||
if (currentModelUrl) modes.push("mesh");
|
||||
return modes;
|
||||
}, [currentModelUrl, currentPrimarySpzUrl, currentWorldAsset?.worldUrl, currentWorldImageUrl]);
|
||||
const preferredWorldViewportMode: WorldViewportMode =
|
||||
currentPrimarySpzUrl
|
||||
? "splat"
|
||||
: currentWorldAsset?.worldUrl
|
||||
? "world"
|
||||
: currentWorldImageUrl
|
||||
? "imagery"
|
||||
: currentModelUrl
|
||||
? "mesh"
|
||||
: "world";
|
||||
const activeWorkspaceTitle =
|
||||
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
|
||||
const viewerEmptyStateTitle =
|
||||
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
|
||||
const waitingTaskLabel =
|
||||
workspaceMode === "world" ? "等待创建世界任务" : "等待创建模型任务";
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world") return;
|
||||
setWorldViewportMode(preferredWorldViewportMode);
|
||||
}, [workspaceMode, currentTask?.taskId, preferredWorldViewportMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world") return;
|
||||
if (availableWorldViewportModes.length === 0) return;
|
||||
if (!availableWorldViewportModes.includes(worldViewportMode)) {
|
||||
setWorldViewportMode(preferredWorldViewportMode);
|
||||
}
|
||||
}, [
|
||||
availableWorldViewportModes,
|
||||
preferredWorldViewportMode,
|
||||
workspaceMode,
|
||||
worldViewportMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
|
||||
@ -1962,7 +2098,7 @@ export default function CanvasModel3DPage() {
|
||||
className="h-8 gap-1 px-2 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
variant="ghost"
|
||||
>
|
||||
3D 工作台
|
||||
{activeWorkspaceTitle}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</nav>
|
||||
@ -2764,11 +2900,11 @@ export default function CanvasModel3DPage() {
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
disabled={!currentWorldAsset?.panoUrl}
|
||||
disabled={!currentWorldImageUrl}
|
||||
onClick={handleOpenPanoUrl}
|
||||
variant="outline"
|
||||
>
|
||||
<PanelTop className="mr-2 h-4 w-4" /> 打开全景
|
||||
<ImageIcon className="mr-2 h-4 w-4" /> 打开图像
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -2776,7 +2912,7 @@ export default function CanvasModel3DPage() {
|
||||
<CardHeader className="space-y-1 pb-3">
|
||||
<CardTitle className="text-sm">当前世界资产</CardTitle>
|
||||
<CardDescription className="text-xs text-zinc-500">
|
||||
第一版支持将 World Labs 结果以 mesh 形式导入画布,并保留 World 与全景链接。
|
||||
当前支持以原生 world3d 节点导入画布,并保留 World、Splat、图像与 Mesh 入口。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs text-zinc-400">
|
||||
@ -2821,16 +2957,14 @@ export default function CanvasModel3DPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<PanelTop className="h-3.5 w-3.5" />
|
||||
Panorama
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
图像预览
|
||||
</span>
|
||||
<Badge
|
||||
className="text-[10px]"
|
||||
variant={
|
||||
currentWorldAsset?.panoUrl ? "default" : "secondary"
|
||||
}
|
||||
variant={currentWorldImageUrl ? "default" : "secondary"}
|
||||
>
|
||||
{currentWorldAsset?.panoUrl ? "可用" : "暂无"}
|
||||
{currentWorldImageUrl ? "可用" : "暂无"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||||
@ -2967,9 +3101,9 @@ export default function CanvasModel3DPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||||
<div className="text-[11px] text-zinc-500">全景图</div>
|
||||
<div className="text-[11px] text-zinc-500">图像预览</div>
|
||||
<div className="truncate text-zinc-200">
|
||||
{currentWorldAsset?.panoUrl ? "可访问" : "暂无"}
|
||||
{currentWorldImageUrl ? "可访问" : "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||||
@ -3031,7 +3165,7 @@ export default function CanvasModel3DPage() {
|
||||
>
|
||||
{activeProviderLabel}
|
||||
</Badge>
|
||||
{currentTask?.provider === "worldlabs-3d" && (
|
||||
{workspaceMode === "world" && (
|
||||
<Badge
|
||||
className="border-sky-500/30 bg-sky-500/10 text-sky-300"
|
||||
variant="outline"
|
||||
@ -3045,7 +3179,7 @@ export default function CanvasModel3DPage() {
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-zinc-500">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>{activeRelativeTime || "等待创建任务"}</span>
|
||||
<span>{activeRelativeTime || waitingTaskLabel}</span>
|
||||
{currentWorldId && (
|
||||
<>
|
||||
<span className="text-zinc-700">•</span>
|
||||
@ -3054,73 +3188,136 @@ export default function CanvasModel3DPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Model3DViewer
|
||||
layerVisibility={effectiveLayerVisibility}
|
||||
modelUrl={currentModelUrl}
|
||||
onLayersChange={handleLayersChange}
|
||||
onStatsChange={setViewerStats}
|
||||
previewImageUrl={currentPreviewImage}
|
||||
settings={viewerSettings}
|
||||
/>
|
||||
{workspaceMode === "world" && (
|
||||
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/75 p-2 shadow-2xl backdrop-blur-md">
|
||||
{[
|
||||
{
|
||||
id: "splat" as WorldViewportMode,
|
||||
label: "Splat",
|
||||
icon: Sparkles,
|
||||
disabled: !currentPrimarySpzUrl,
|
||||
},
|
||||
{
|
||||
id: "world" as WorldViewportMode,
|
||||
label: "World",
|
||||
icon: Globe,
|
||||
disabled: !currentWorldAsset?.worldUrl,
|
||||
},
|
||||
{
|
||||
id: "imagery" as WorldViewportMode,
|
||||
label: "图像",
|
||||
icon: ImageIcon,
|
||||
disabled: !currentWorldImageUrl,
|
||||
},
|
||||
{
|
||||
id: "mesh" as WorldViewportMode,
|
||||
label: "Mesh",
|
||||
icon: Box,
|
||||
disabled: !currentModelUrl,
|
||||
},
|
||||
].map((view) => (
|
||||
<Button
|
||||
className="gap-2 rounded-full"
|
||||
disabled={view.disabled}
|
||||
key={view.id}
|
||||
onClick={() => setWorldViewportMode(view.id)}
|
||||
size="sm"
|
||||
variant={worldViewportMode === view.id ? "secondary" : "ghost"}
|
||||
>
|
||||
<view.icon className="h-4 w-4" />
|
||||
{view.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMode === "world" ? (
|
||||
<World3DViewer
|
||||
emptyStateTitle={viewerEmptyStateTitle}
|
||||
imageUrl={currentWorldImageUrl}
|
||||
meshLayerVisibility={effectiveLayerVisibility}
|
||||
meshSettings={viewerSettings}
|
||||
meshUrl={currentModelUrl}
|
||||
mode={worldViewportMode === "imagery" ? "image" : worldViewportMode}
|
||||
onMeshLayersChange={handleLayersChange}
|
||||
onMeshStatsChange={setViewerStats}
|
||||
previewImageUrl={currentPreviewImage}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
splatUrl={currentPrimarySpzUrl}
|
||||
worldUrl={currentWorldAsset?.worldUrl}
|
||||
/>
|
||||
) : (
|
||||
<Model3DViewer
|
||||
emptyStateTitle={viewerEmptyStateTitle}
|
||||
layerVisibility={effectiveLayerVisibility}
|
||||
modelUrl={currentModelUrl}
|
||||
onLayersChange={handleLayersChange}
|
||||
onStatsChange={setViewerStats}
|
||||
previewImageUrl={currentPreviewImage}
|
||||
settings={viewerSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Toolbar */}
|
||||
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||||
onClick={() =>
|
||||
{(workspaceMode !== "world" || worldViewportMode === "mesh") && (
|
||||
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||||
onClick={() =>
|
||||
setViewerSettings((prev) => ({
|
||||
...prev,
|
||||
showGrid: !prev.showGrid,
|
||||
}))
|
||||
}
|
||||
size="icon"
|
||||
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>网格</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||||
onClick={() =>
|
||||
setViewerSettings((prev) => ({
|
||||
...prev,
|
||||
autoRotate: !prev.autoRotate,
|
||||
}))
|
||||
}
|
||||
size="icon"
|
||||
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>自动旋转</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator className="h-4 bg-white/20" orientation="vertical" />
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Sun className="h-3 w-3 text-zinc-400" />
|
||||
<Slider
|
||||
className="w-20"
|
||||
max={2}
|
||||
min={0.2}
|
||||
onValueChange={(v) =>
|
||||
setViewerSettings((prev) => ({
|
||||
...prev,
|
||||
showGrid: !prev.showGrid,
|
||||
lightIntensity: v[0] || 1,
|
||||
}))
|
||||
}
|
||||
size="icon"
|
||||
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>网格</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||||
onClick={() =>
|
||||
setViewerSettings((prev) => ({
|
||||
...prev,
|
||||
autoRotate: !prev.autoRotate,
|
||||
}))
|
||||
}
|
||||
size="icon"
|
||||
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>自动旋转</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator className="h-4 bg-white/20" orientation="vertical" />
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Sun className="h-3 w-3 text-zinc-400" />
|
||||
<Slider
|
||||
className="w-20"
|
||||
max={2}
|
||||
min={0.2}
|
||||
onValueChange={(v) =>
|
||||
setViewerSettings((prev) => ({
|
||||
...prev,
|
||||
lightIntensity: v[0] || 1,
|
||||
}))
|
||||
}
|
||||
step={0.1}
|
||||
value={[viewerSettings.lightIntensity]}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
step={0.1}
|
||||
value={[viewerSettings.lightIntensity]}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Right Assets Panel */}
|
||||
|
||||
@ -9,6 +9,7 @@ import { ImageNode } from "./nodes/ImageNode";
|
||||
import { Model3DNode } from "./nodes/Model3DNode";
|
||||
import { TextNode } from "./nodes/TextNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { World3DNode } from "./nodes/World3DNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
@ -1036,6 +1037,9 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
||||
{node.type === 'model3d' && (
|
||||
<Model3DNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'world3d' && (
|
||||
<World3DNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'message' && (
|
||||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
||||
<span className="text-sm">{(node.data as any).content}</span>
|
||||
|
||||
@ -48,6 +48,7 @@ export type Model3DViewerSettings = {
|
||||
type Model3DViewerProps = {
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
emptyStateTitle?: string;
|
||||
settings: Model3DViewerSettings;
|
||||
layerVisibility?: Record<string, boolean>;
|
||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
@ -189,7 +190,13 @@ function isGlbOrGltf(url?: string | null) {
|
||||
return /\.(gltf|glb)(\?|#|$)/.test(value);
|
||||
}
|
||||
|
||||
function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
|
||||
function EmptyState({
|
||||
previewImageUrl,
|
||||
title,
|
||||
}: {
|
||||
previewImageUrl?: string | null;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/50 p-4">
|
||||
{previewImageUrl ? (
|
||||
@ -208,7 +215,7 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/50 shadow-inner">
|
||||
<Box className="h-10 w-10 opacity-20" />
|
||||
</div>
|
||||
<p className="font-medium text-sm">准备生成 3D 资产</p>
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -218,6 +225,7 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
|
||||
export function Model3DViewer({
|
||||
modelUrl,
|
||||
previewImageUrl,
|
||||
emptyStateTitle = "准备生成 3D 资产",
|
||||
settings,
|
||||
layerVisibility,
|
||||
onLayersChange,
|
||||
@ -239,7 +247,12 @@ export function Model3DViewer({
|
||||
}, [modelUrl]);
|
||||
|
||||
if (!modelUrl) {
|
||||
return <EmptyState previewImageUrl={previewImageUrl} />;
|
||||
return (
|
||||
<EmptyState
|
||||
previewImageUrl={previewImageUrl}
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlbOrGltf(modelUrl)) {
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
@ -53,21 +55,54 @@ function inferModelFormat(data: Model3DNodeData) {
|
||||
return "3D";
|
||||
}
|
||||
|
||||
function readWorldAssetPayload(data: Model3DNodeData) {
|
||||
const raw = data.metadata?.assetPayload;
|
||||
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function readOptionalUrl(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
const data = node.data as Model3DNodeData;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
const providerLabel =
|
||||
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
|
||||
const worldAssetPayload = readWorldAssetPayload(data);
|
||||
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
|
||||
const worldImageUrl =
|
||||
readOptionalUrl(worldAssetPayload?.panoUrl) ||
|
||||
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
|
||||
readOptionalUrl(data.previewImage);
|
||||
const primaryOpenUrl = worldUrl || data.src || null;
|
||||
const primaryOpenLabel = worldUrl ? "打开 World" : "打开 3D";
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!primaryOpenUrl) {
|
||||
toast.error("暂无可打开的地址");
|
||||
return;
|
||||
}
|
||||
window.open(primaryOpenUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleOpenMesh = () => {
|
||||
if (!data.src) {
|
||||
toast.error("暂无可打开的 3D 地址");
|
||||
toast.error("暂无可打开的 Mesh 地址");
|
||||
return;
|
||||
}
|
||||
window.open(data.src, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleOpenImage = () => {
|
||||
if (!worldImageUrl) {
|
||||
toast.error("暂无可打开的图像预览");
|
||||
return;
|
||||
}
|
||||
window.open(worldImageUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!data.src) {
|
||||
toast.error("暂无可下载的 3D 文件");
|
||||
@ -80,13 +115,13 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data.src) {
|
||||
if (!primaryOpenUrl) {
|
||||
toast.error("暂无可复制的地址");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.src);
|
||||
toast.success("已复制 3D 链接");
|
||||
await navigator.clipboard.writeText(primaryOpenUrl);
|
||||
toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接");
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
}
|
||||
@ -104,12 +139,20 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
{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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.metadata?.provider === "worldlabs-3d"
|
||||
? "3D 世界生成中..."
|
||||
: "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>
|
||||
<span className="text-xs text-destructive">
|
||||
{data.metadata?.provider === "worldlabs-3d"
|
||||
? "3D 世界生成失败"
|
||||
: "3D 生成失败"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
@ -148,7 +191,7 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpen}
|
||||
title="打开3D"
|
||||
title={primaryOpenLabel}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -160,9 +203,25 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleOpen}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
打开 3D
|
||||
{worldUrl ? (
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{primaryOpenLabel}
|
||||
</DropdownMenuItem>
|
||||
{worldUrl && data.src && data.src !== worldUrl && (
|
||||
<DropdownMenuItem onClick={handleOpenMesh}>
|
||||
<Box className="mr-2 h-4 w-4" />
|
||||
打开 Mesh
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{worldImageUrl && (
|
||||
<DropdownMenuItem onClick={handleOpenImage}>
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
打开图像
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
|
||||
373
components/canvas/nodes/World3DNode.tsx
Normal file
373
components/canvas/nodes/World3DNode.tsx
Normal file
@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
Copy,
|
||||
Crosshair,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
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 { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
|
||||
import { cn } 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: "S", icon: Sparkles },
|
||||
{ id: "world", label: "W", icon: Globe },
|
||||
{ id: "image", label: "I", icon: ImageIcon },
|
||||
{ id: "mesh", label: "M", icon: Box },
|
||||
];
|
||||
|
||||
function resolveAvailableViews(data: World3DNodeData) {
|
||||
const views: World3DViewMode[] = [];
|
||||
if (data.splatUrl) views.push("splat");
|
||||
if (data.worldUrl) views.push("world");
|
||||
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.worldUrl || data.splatUrl || data.meshUrl || data.imageUrl || null;
|
||||
case "image":
|
||||
return data.imageUrl || data.previewImage || data.worldUrl || data.meshUrl || null;
|
||||
case "mesh":
|
||||
return data.meshUrl || data.splatUrl || data.worldUrl || null;
|
||||
case "splat":
|
||||
default:
|
||||
return data.splatUrl || data.worldUrl || data.meshUrl || data.imageUrl || 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 activeView = useMemo(() => resolveActiveView(data), [data]);
|
||||
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
|
||||
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 handleOpen = () => {
|
||||
if (!primaryUrl) {
|
||||
toast.error("暂无可打开的世界资产");
|
||||
return;
|
||||
}
|
||||
window.open(primaryUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
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 || "World Labs 世界"}
|
||||
</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">
|
||||
World Labs
|
||||
</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">
|
||||
{activeView.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
{data.modelVersion || "原生世界节点"}
|
||||
</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">
|
||||
<Button
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={handleOpen}
|
||||
size="icon"
|
||||
title="打开世界"
|
||||
variant="secondary"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<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.worldUrl ? (
|
||||
<DropdownMenuItem onClick={() => window.open(data.worldUrl, "_blank", "noopener,noreferrer")}>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
打开 World
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{data.splatUrl ? (
|
||||
<DropdownMenuItem onClick={() => window.open(data.splatUrl, "_blank", "noopener,noreferrer")}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
打开 Splat
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
534
components/canvas/world3d/World3DViewer.tsx
Normal file
534
components/canvas/world3d/World3DViewer.tsx
Normal file
@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Html, OrbitControls, PointerLockControls } from "@react-three/drei";
|
||||
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber";
|
||||
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
Crosshair,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
type Model3DLayer,
|
||||
type Model3DStats,
|
||||
Model3DViewer,
|
||||
type Model3DViewerSettings,
|
||||
} from "@/components/canvas/model3d/Model3DViewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
|
||||
|
||||
type World3DPickResult = {
|
||||
point: [number, number, number];
|
||||
};
|
||||
|
||||
type World3DViewerProps = {
|
||||
mode: World3DViewMode;
|
||||
worldUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
meshUrl?: string | null;
|
||||
splatUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
emptyStateTitle?: string;
|
||||
meshSettings?: Model3DViewerSettings;
|
||||
meshLayerVisibility?: Record<string, boolean>;
|
||||
onMeshLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
onMeshStatsChange?: (stats: Model3DStats) => void;
|
||||
firstPerson?: boolean;
|
||||
hotspots?: World3DHotspot[];
|
||||
interactionMode?: "navigate" | "hotspot";
|
||||
onPick?: (pick: World3DPickResult) => void;
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
envPreset: "city",
|
||||
lightIntensity: 1,
|
||||
autoRotate: false,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
};
|
||||
|
||||
function getSemanticFacts(metadata?: Record<string, unknown> | null) {
|
||||
if (!metadata) return [];
|
||||
|
||||
const facts: Array<{ label: string; value: string }> = [];
|
||||
const scaleFactor = metadata.metric_scale_factor;
|
||||
if (typeof scaleFactor === "number" && Number.isFinite(scaleFactor)) {
|
||||
facts.push({
|
||||
label: "比例尺",
|
||||
value: `${scaleFactor.toFixed(3)}x`,
|
||||
});
|
||||
}
|
||||
|
||||
const groundOffset = metadata.ground_plane_offset;
|
||||
if (typeof groundOffset === "number" && Number.isFinite(groundOffset)) {
|
||||
facts.push({
|
||||
label: "地平面偏移",
|
||||
value: `${groundOffset.toFixed(2)}m`,
|
||||
});
|
||||
}
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
function WorldEmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
previewImageUrl,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
previewImageUrl?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/70 p-6 text-center">
|
||||
{previewImageUrl ? (
|
||||
<div className="relative aspect-video w-full max-w-xl overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-2xl">
|
||||
<img
|
||||
alt={title}
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
src={previewImageUrl}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-[1px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
|
||||
<Icon className="h-10 w-10 text-zinc-700" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-lg text-zinc-200">{title}</p>
|
||||
<p className="max-w-md text-sm text-zinc-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FirstPersonMovement({ enabled }: { enabled: boolean }) {
|
||||
const { camera } = useThree();
|
||||
const pressedKeysRef = useRef<Record<string, boolean>>({});
|
||||
const directionRef = useRef(new THREE.Vector3());
|
||||
const rightRef = useRef(new THREE.Vector3());
|
||||
const upRef = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
pressedKeysRef.current = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
pressedKeysRef.current[event.code] = true;
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
pressedKeysRef.current[event.code] = false;
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const pressed = pressedKeysRef.current;
|
||||
const moveSpeed = pressed.ShiftLeft || pressed.ShiftRight ? 6.5 : 3.4;
|
||||
const distance = moveSpeed * delta;
|
||||
|
||||
camera.getWorldDirection(directionRef.current);
|
||||
directionRef.current.normalize();
|
||||
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
|
||||
upRef.current.copy(camera.up).normalize();
|
||||
|
||||
if (pressed.KeyW) camera.position.addScaledVector(directionRef.current, distance);
|
||||
if (pressed.KeyS) camera.position.addScaledVector(directionRef.current, -distance);
|
||||
if (pressed.KeyA) camera.position.addScaledVector(rightRef.current, -distance);
|
||||
if (pressed.KeyD) camera.position.addScaledVector(rightRef.current, distance);
|
||||
if (pressed.KeyQ) camera.position.addScaledVector(upRef.current, distance);
|
||||
if (pressed.KeyE) camera.position.addScaledVector(upRef.current, -distance);
|
||||
});
|
||||
|
||||
if (!enabled) return null;
|
||||
return <PointerLockControls makeDefault />;
|
||||
}
|
||||
|
||||
function HotspotBillboard({
|
||||
hotspot,
|
||||
onSelect,
|
||||
}: {
|
||||
hotspot: World3DHotspot;
|
||||
onSelect?: (hotspotId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<group position={[hotspot.position.x, hotspot.position.y, hotspot.position.z]}>
|
||||
<mesh
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.(hotspot.id);
|
||||
}}
|
||||
>
|
||||
<sphereGeometry args={[0.07, 24, 24]} />
|
||||
<meshStandardMaterial color="#facc15" emissive="#ca8a04" emissiveIntensity={0.8} />
|
||||
</mesh>
|
||||
<Html center distanceFactor={8} position={[0, 0.14, 0]}>
|
||||
<div className="rounded-full border border-yellow-400/30 bg-black/70 px-2 py-1 text-[10px] font-medium text-yellow-200 shadow-lg backdrop-blur-sm">
|
||||
{hotspot.label}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldSplatScene({
|
||||
splatUrl,
|
||||
firstPerson = false,
|
||||
hotspots = [],
|
||||
interactionMode = "navigate",
|
||||
onPick,
|
||||
onHotspotSelect,
|
||||
semanticsMetadata,
|
||||
onLoadStateChange,
|
||||
}: {
|
||||
splatUrl: string;
|
||||
firstPerson?: boolean;
|
||||
hotspots?: World3DHotspot[];
|
||||
interactionMode?: "navigate" | "hotspot";
|
||||
onPick?: (pick: World3DPickResult) => void;
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
onLoadStateChange?: (state: "ready" | "error") => void;
|
||||
}) {
|
||||
const renderer = useThree((state) => state.gl);
|
||||
const semanticFacts = useMemo(
|
||||
() => getSemanticFacts(semanticsMetadata),
|
||||
[semanticsMetadata]
|
||||
);
|
||||
const sparkRenderer = useMemo(
|
||||
() =>
|
||||
new SparkRenderer({
|
||||
renderer,
|
||||
blurAmount: 0.2,
|
||||
maxStdDev: Math.sqrt(7),
|
||||
}),
|
||||
[renderer]
|
||||
);
|
||||
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
sparkRenderer.frustumCulled = false;
|
||||
sparkRenderer.renderOrder = 10;
|
||||
return () => {
|
||||
sparkRenderer.removeFromParent();
|
||||
};
|
||||
}, [sparkRenderer]);
|
||||
|
||||
useEffect(() => {
|
||||
splatMesh.frustumCulled = false;
|
||||
let cancelled = false;
|
||||
splatMesh.initialized
|
||||
.then(() => {
|
||||
if (!cancelled) onLoadStateChange?.("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) onLoadStateChange?.("error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
splatMesh.dispose();
|
||||
};
|
||||
}, [onLoadStateChange, splatMesh]);
|
||||
|
||||
useFrame(() => {
|
||||
sparkRenderer.needsUpdate = true;
|
||||
});
|
||||
|
||||
const groundPlaneOffset =
|
||||
typeof semanticsMetadata?.ground_plane_offset === "number"
|
||||
? semanticsMetadata.ground_plane_offset
|
||||
: null;
|
||||
|
||||
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
|
||||
if (interactionMode !== "hotspot") return;
|
||||
event.stopPropagation();
|
||||
onPick?.({
|
||||
point: [event.point.x, event.point.y, event.point.z],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<color args={["#020617"]} attach="background" />
|
||||
<fog attach="fog" args={["#020617", 10, 45]} />
|
||||
<ambientLight intensity={1.6} />
|
||||
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
||||
{groundPlaneOffset !== null ? (
|
||||
<gridHelper
|
||||
args={[18, 18, "#334155", "#0f172a"]}
|
||||
position={[0, groundPlaneOffset, 0]}
|
||||
/>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<primitive object={sparkRenderer}>
|
||||
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
||||
</primitive>
|
||||
</Suspense>
|
||||
{hotspots.map((hotspot) => (
|
||||
<HotspotBillboard
|
||||
hotspot={hotspot}
|
||||
key={hotspot.id}
|
||||
onSelect={onHotspotSelect}
|
||||
/>
|
||||
))}
|
||||
{!firstPerson ? (
|
||||
<OrbitControls
|
||||
autoRotate={false}
|
||||
enablePan
|
||||
enableRotate
|
||||
enableZoom
|
||||
makeDefault
|
||||
maxDistance={16}
|
||||
minDistance={0.5}
|
||||
/>
|
||||
) : (
|
||||
<FirstPersonMovement enabled />
|
||||
)}
|
||||
<Html className="pointer-events-none" fullscreen>
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
|
||||
SPZ
|
||||
</Badge>
|
||||
{semanticFacts.map((fact) => (
|
||||
<Badge
|
||||
className="border-white/10 bg-black/50 text-zinc-200"
|
||||
key={fact.label}
|
||||
variant="outline"
|
||||
>
|
||||
{fact.label} {fact.value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{interactionMode === "hotspot" ? (
|
||||
<div className="rounded-full border border-yellow-400/30 bg-black/65 px-3 py-1 text-[11px] text-yellow-100">
|
||||
点击场景放置热点
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-zinc-300 backdrop-blur-sm">
|
||||
{firstPerson ? "WASD 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"}
|
||||
</div>
|
||||
{(firstPerson || interactionMode === "hotspot") && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
|
||||
<Crosshair className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldSplatViewer({
|
||||
splatUrl,
|
||||
previewImageUrl,
|
||||
firstPerson,
|
||||
hotspots,
|
||||
interactionMode,
|
||||
onPick,
|
||||
onHotspotSelect,
|
||||
semanticsMetadata,
|
||||
}: {
|
||||
splatUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
firstPerson?: boolean;
|
||||
hotspots?: World3DHotspot[];
|
||||
interactionMode?: "navigate" | "hotspot";
|
||||
onPick?: (pick: World3DPickResult) => void;
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
useEffect(() => {
|
||||
setLoadState("loading");
|
||||
}, [splatUrl]);
|
||||
|
||||
if (!splatUrl) {
|
||||
return (
|
||||
<WorldEmptyState
|
||||
description="当前结果没有返回可用的 SPZ 世界资产。"
|
||||
icon={Sparkles}
|
||||
previewImageUrl={previewImageUrl}
|
||||
title="准备加载 Splat 世界"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState === "error") {
|
||||
return (
|
||||
<WorldEmptyState
|
||||
description="SPZ 资源暂时无法加载,可以切换到 World、图像或 Mesh 视图继续查看。"
|
||||
icon={AlertCircle}
|
||||
previewImageUrl={previewImageUrl}
|
||||
title="Splat 视图加载失败"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<Canvas
|
||||
camera={{ position: [0, 1.6, 4], fov: 55 }}
|
||||
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
|
||||
frameloop="always"
|
||||
gl={{ antialias: false }}
|
||||
>
|
||||
<WorldSplatScene
|
||||
firstPerson={firstPerson}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onLoadStateChange={setLoadState}
|
||||
onPick={onPick}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
splatUrl={splatUrl}
|
||||
/>
|
||||
</Canvas>
|
||||
{loadState === "loading" ? (
|
||||
<div className="absolute inset-0">
|
||||
<WorldEmptyState
|
||||
description="正在初始化 Gaussian Splat 资源,这一步会比普通 Mesh 略慢。"
|
||||
icon={Sparkles}
|
||||
previewImageUrl={previewImageUrl}
|
||||
title="正在加载 Splat 世界"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function World3DViewer({
|
||||
mode,
|
||||
worldUrl,
|
||||
imageUrl,
|
||||
meshUrl,
|
||||
splatUrl,
|
||||
previewImageUrl,
|
||||
emptyStateTitle = "准备生成 3D 世界",
|
||||
meshSettings,
|
||||
meshLayerVisibility,
|
||||
onMeshLayersChange,
|
||||
onMeshStatsChange,
|
||||
firstPerson = false,
|
||||
hotspots = [],
|
||||
interactionMode = "navigate",
|
||||
onPick,
|
||||
onHotspotSelect,
|
||||
semanticsMetadata,
|
||||
className,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
if (mode === "mesh") {
|
||||
return (
|
||||
<div className={cn("h-full w-full", className)}>
|
||||
<Model3DViewer
|
||||
emptyStateTitle={emptyStateTitle}
|
||||
layerVisibility={meshLayerVisibility}
|
||||
modelUrl={meshUrl}
|
||||
onLayersChange={onMeshLayersChange}
|
||||
onStatsChange={onMeshStatsChange}
|
||||
previewImageUrl={previewImageUrl}
|
||||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "world") {
|
||||
return (
|
||||
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}>
|
||||
{worldUrl ? (
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
|
||||
allowFullScreen
|
||||
className="h-full w-full border-0"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={worldUrl}
|
||||
title="World Labs 世界预览"
|
||||
/>
|
||||
) : (
|
||||
<WorldEmptyState
|
||||
description="当前结果还没有可嵌入的 World 页面链接,可以先切换到 Splat、图像或 Mesh 视图。"
|
||||
icon={Globe}
|
||||
previewImageUrl={previewImageUrl}
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "image") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{resolvedImageUrl ? (
|
||||
<img
|
||||
alt="世界图像预览"
|
||||
className="max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
|
||||
src={resolvedImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<WorldEmptyState
|
||||
description="当前结果还没有可用的图像资产,可以先切换到 Splat 或 Mesh 视图。"
|
||||
icon={ImageIcon}
|
||||
previewImageUrl={previewImageUrl}
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("h-full w-full", className)}>
|
||||
<WorldSplatViewer
|
||||
firstPerson={firstPerson}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onPick={onPick}
|
||||
previewImageUrl={previewImageUrl}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
splatUrl={splatUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
// 外部包配置(Next.js 16+)
|
||||
serverExternalPackages: ["sharp"],
|
||||
transpilePackages: ["@sparkjsdev/spark"],
|
||||
// 实验性功能:增加代理请求体大小限制以支持大文件上传(视频等)
|
||||
experimental: {
|
||||
proxyClientMaxBodySize: "500mb", // 代理/中间件请求体限制
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sparkjsdev/spark": "^0.1.10",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/blob": "^0.24.1",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -95,6 +95,9 @@ importers:
|
||||
'@react-three/fiber':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
'@sparkjsdev/spark':
|
||||
specifier: ^0.1.10
|
||||
version: 0.1.10
|
||||
'@types/form-data':
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
@ -2859,6 +2862,9 @@ packages:
|
||||
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@sparkjsdev/spark@0.1.10':
|
||||
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
@ -8925,6 +8931,10 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@sparkjsdev/spark@0.1.10':
|
||||
dependencies:
|
||||
fflate: 0.8.2
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
@ -2,7 +2,14 @@ import type { AgentWorkflowPersistedState } from "@/types/agent";
|
||||
|
||||
// 画布节点类型定义
|
||||
|
||||
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d';
|
||||
export type NodeType =
|
||||
| 'message'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'text'
|
||||
| 'scene-fusion'
|
||||
| 'model3d'
|
||||
| 'world3d';
|
||||
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
|
||||
|
||||
@ -115,6 +122,38 @@ export interface Model3DNodeData {
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type World3DViewMode = 'world' | 'image' | 'mesh' | 'splat';
|
||||
|
||||
export interface World3DHotspot {
|
||||
id: string;
|
||||
label: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// 3D 世界节点数据
|
||||
export interface World3DNodeData {
|
||||
worldUrl?: string;
|
||||
imageUrl?: string;
|
||||
meshUrl?: string;
|
||||
splatUrl?: string;
|
||||
assetId?: string;
|
||||
previewImage?: string;
|
||||
prompt?: string;
|
||||
modelVersion?: string;
|
||||
status: NodeStatus;
|
||||
activeView?: World3DViewMode;
|
||||
firstPerson?: boolean;
|
||||
hotspots?: World3DHotspot[];
|
||||
semanticsMetadata?: Record<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 画布节点
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
@ -127,7 +166,8 @@ export interface CanvasNode {
|
||||
| VideoNodeData
|
||||
| TextNodeData
|
||||
| SceneFusionNodeData
|
||||
| Model3DNodeData;
|
||||
| Model3DNodeData
|
||||
| World3DNodeData;
|
||||
connections: string[]; // 连接的节点 ID
|
||||
parentId?: string; // 父节点 ID(用于对话上下文)
|
||||
createdAt: Date;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user