"use client"; import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; import { ArrowLeft, Bone, Box, Boxes, ChevronDown, ChevronUp, Download, Eye, EyeOff, Grid3X3, History, Home, ImagePlus, Images, Layers3, LayoutGrid, Loader2, Maximize, Package, Palette, PenLine, Play, PlusCircle, RefreshCw, RotateCw, Scissors, Settings2, Sparkles, Sun, WandSparkles, X, } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { type Model3DLayer, type Model3DStats, Model3DViewer, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn, fetcher } from "@/lib/utils"; import { canvasService, type Model3DTaskStatus, model3DGenerationService, } from "@/services/canvas"; import { canvasSelectors, useCanvasStore } from "@/store/canvas"; type GenerationMode = "text" | "image" | "multiview" | "batch"; type TaskMode = | GenerationMode | "convert" | "refine" | "texture" | "stylize" | "rigcheck" | "rig" | "retarget" | "segment" | "complete" | "lowpoly"; type TaskRecord = { taskId: string; status: Model3DTaskStatus; mode: TaskMode; prompt: string; modelVersion: string; createdAt: number; progress?: number | null; modelUrl?: string | null; previewImageUrl?: string | null; error?: string | null; sourceTaskId?: string | null; exportFormat?: string | null; }; type TaskRecordApiEntity = { taskId?: unknown; status?: unknown; mode?: unknown; prompt?: unknown; modelVersion?: unknown; progress?: unknown; modelUrl?: unknown; previewImageUrl?: unknown; error?: unknown; sourceTaskId?: unknown; exportFormat?: unknown; createdAt?: unknown; }; type MultiViewSlot = { id: string; file: File | null; previewUrl: string | null; }; const MODEL_VERSION_OPTIONS = [ "Turbo-v1.0-20250506", "v3.1-20260211", "v3.0-20250812", "v2.5-20250123", "v2.0-20240919", "v1.4-20240625", ] as const; const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [ "city", "sunset", "studio", "dawn", "night", "warehouse", "forest", "apartment", "park", "lobby", ]; const EXPORT_FORMAT_OPTIONS = [ "GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF", ] as const; const STYLE_OPTIONS = ["lego", "voxel", "voronoi", "minecraft"] as const; const RIG_TYPE_OPTIONS = [ "biped", "quadruped", "hexapod", "octopod", "avian", "serpentine", "aquatic", "others", ] as const; const RIG_SPEC_OPTIONS = ["tripo", "mixamo"] as const; const RIG_FORMAT_OPTIONS = ["glb", "fbx"] as const; const ANIMATION_OPTIONS = [ "preset:idle", "preset:walk", "preset:run", "preset:jump", "preset:turn", "preset:slash", "preset:shoot", "preset:hurt", "preset:fall", ] as const; const FINAL_TASK_STATUS = new Set([ "success", "failed", "banned", "expired", "cancelled", "unknown", ]); const TASK_MODE_SET = new Set([ "text", "image", "multiview", "batch", "convert", "refine", "texture", "stylize", "rigcheck", "rig", "retarget", "segment", "complete", "lowpoly", ]); const TASK_HISTORY_LIMIT = 40; const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2"; function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus { switch (status) { case "queued": case "running": case "success": case "failed": case "banned": case "expired": case "cancelled": case "unknown": return status; default: return "unknown"; } } function extractModelFormat(modelUrl?: string | null): string { const value = String(modelUrl || "").toLowerCase(); if (!value) return "3d"; if (value.includes(".gltf")) return "gltf"; if (value.includes(".glb")) return "glb"; if (value.includes(".usdz")) return "usdz"; if (value.includes(".fbx")) return "fbx"; if (value.includes(".obj")) return "obj"; if (value.includes(".stl")) return "stl"; if (value.includes(".3mf")) return "3mf"; return "3d"; } function createMultiViewSlots(count = 4): MultiViewSlot[] { return Array.from({ length: count }, (_, index) => ({ id: `slot_${index + 1}`, file: null, previewUrl: null, })); } function createBatchImageSlots(count = 10): MultiViewSlot[] { return Array.from({ length: count }, (_, index) => ({ id: `batch_slot_${index + 1}`, file: null, previewUrl: null, })); } function modeLabel(mode: TaskMode) { switch (mode) { case "text": return "文生3D"; case "image": return "图生3D"; case "multiview": return "多视图3D"; case "batch": return "批量图生3D"; case "convert": return "格式转换"; case "refine": return "模型精修"; case "texture": return "纹理重绘"; case "stylize": return "风格化"; case "rigcheck": return "可绑定检测"; case "rig": return "自动绑定"; case "retarget": return "动作重定向"; case "segment": return "网格分割"; case "complete": return "网格补全"; case "lowpoly": return "低模优化"; default: return mode; } } function statusLabel(status: Model3DTaskStatus) { switch (status) { case "queued": return "排队中"; case "running": return "生成中"; case "success": return "成功"; case "failed": return "失败"; case "banned": return "审核拦截"; case "expired": return "已过期"; case "cancelled": return "已取消"; default: return "未知"; } } function makeStorageKey(projectId: string) { return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`; } function normalizeTaskMode(mode: unknown): TaskMode { const value = String(mode || "").trim() as TaskMode; return TASK_MODE_SET.has(value) ? value : "text"; } function toTaskTimestamp(value: unknown): number { if (value instanceof Date) return value.getTime(); if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Date.parse(value); if (Number.isFinite(parsed)) return parsed; } return Date.now(); } function normalizeTaskRecord(raw: TaskRecord): TaskRecord { return { ...raw, taskId: String(raw.taskId || "").trim(), status: normalizeTaskStatus(raw.status), mode: normalizeTaskMode(raw.mode), prompt: String(raw.prompt || "").trim(), modelVersion: String(raw.modelVersion || "").trim(), createdAt: toTaskTimestamp(raw.createdAt), progress: raw.progress === undefined || raw.progress === null ? null : Number.isFinite(Number(raw.progress)) ? Number(raw.progress) : null, modelUrl: raw.modelUrl ? String(raw.modelUrl).trim() : null, previewImageUrl: raw.previewImageUrl ? String(raw.previewImageUrl).trim() : null, error: raw.error ? String(raw.error).trim() : null, sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null, exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null, }; } function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | null { const taskId = String(entity?.taskId || "").trim(); if (!taskId) return null; return normalizeTaskRecord({ taskId, status: normalizeTaskStatus(String(entity?.status || "")), mode: normalizeTaskMode(entity?.mode), prompt: String(entity?.prompt || "").trim(), modelVersion: String(entity?.modelVersion || "").trim(), createdAt: toTaskTimestamp(entity?.createdAt), progress: entity?.progress === undefined || entity?.progress === null ? null : Number(entity.progress), modelUrl: entity?.modelUrl ? String(entity.modelUrl).trim() : null, previewImageUrl: entity?.previewImageUrl ? String(entity.previewImageUrl).trim() : null, error: entity?.error ? String(entity.error).trim() : null, sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null, exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null, }); } function taskRecordChanged(prev: TaskRecord, next: TaskRecord) { return ( prev.status !== next.status || prev.progress !== next.progress || prev.modelUrl !== next.modelUrl || prev.previewImageUrl !== next.previewImageUrl || prev.error !== next.error || prev.prompt !== next.prompt || prev.modelVersion !== next.modelVersion || prev.sourceTaskId !== next.sourceTaskId || prev.exportFormat !== next.exportFormat ); } export default function CanvasModel3DPage() { const router = useRouter(); const routeParams = useParams(); const id = String(routeParams?.id || ""); const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]); const { data: session } = useSession(); const user = session?.user; const viewport = useCanvasStore(canvasSelectors.viewport); const addNode = useCanvasStore((state) => state.addNode); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const [mode, setMode] = useState("text"); const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); const [modelVersion, setModelVersion] = useState< (typeof MODEL_VERSION_OPTIONS)[number] >("Turbo-v1.0-20250506"); const [faceLimit, setFaceLimit] = useState(120_000); const [texture, setTexture] = useState(true); const [pbr, setPbr] = useState(true); const [autoSize, setAutoSize] = useState(false); const [quad, setQuad] = useState(false); const [generateParts, setGenerateParts] = useState(false); const [smartLowPoly, setSmartLowPoly] = useState(false); const [geometryQuality, setGeometryQuality] = useState< "standard" | "detailed" >("standard"); const [textureQuality, setTextureQuality] = useState<"standard" | "detailed">( "standard" ); const [textureAlignment, setTextureAlignment] = useState< "original_image" | "geometry" >("original_image"); const [orientation, setOrientation] = useState<"default" | "align_image">( "default" ); const [modelSeed, setModelSeed] = useState(""); const [textureSeed, setTextureSeed] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [uploadingImage, setUploadingImage] = useState(false); const [referenceImage, setReferenceImage] = useState(null); const [referencePreviewUrl, setReferencePreviewUrl] = useState( null ); const [multiViewSlots, setMultiViewSlots] = useState(() => createMultiViewSlots() ); const [batchImageSlots, setBatchImageSlots] = useState(() => createBatchImageSlots() ); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( null ); const [advancedOpen, setAdvancedOpen] = useState(false); // Viewer Settings const [viewerSettings, setViewerSettings] = useState({ envPreset: "city", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }); const [viewerStats, setViewerStats] = useState({ triangles: 0, vertices: 0, meshCount: 0, }); const [viewerLayers, setViewerLayers] = useState([]); const [layerVisibility, setLayerVisibility] = useState< Record >({}); const [isolatedLayerId, setIsolatedLayerId] = useState(null); // Post Processing States const [exportFormat, setExportFormat] = useState<(typeof EXPORT_FORMAT_OPTIONS)[number]>("GLTF"); const [stylizeStyle, setStylizeStyle] = useState<(typeof STYLE_OPTIONS)[number]>("lego"); const [stylizeBlockSize, setStylizeBlockSize] = useState(80); const [texturePromptText, setTexturePromptText] = useState(""); const [rigType, setRigType] = useState<(typeof RIG_TYPE_OPTIONS)[number]>("biped"); const [rigSpec, setRigSpec] = useState<(typeof RIG_SPEC_OPTIONS)[number]>("tripo"); const [rigFormat, setRigFormat] = useState<(typeof RIG_FORMAT_OPTIONS)[number]>("glb"); const [retargetAnimation, setRetargetAnimation] = useState<(typeof ANIMATION_OPTIONS)[number]>("preset:walk"); const [retargetBake, setRetargetBake] = useState(true); const [retargetExportWithGeometry, setRetargetExportWithGeometry] = useState(false); const [retargetInPlace, setRetargetInPlace] = useState(false); const [completionPartNames, setCompletionPartNames] = useState(""); const [lowpolyFaceLimit, setLowpolyFaceLimit] = useState(30_000); const [lowpolyQuad, setLowpolyQuad] = useState(false); const [lowpolyBake, setLowpolyBake] = useState(true); // UI States const [activeTab, setActiveTab] = useState<"create" | "edit" | "scene">( "create" ); const pollAttemptsRef = useRef(0); const multiViewSlotsRef = useRef([]); const batchImageSlotsRef = useRef([]); // Fetch site settings for Logo const { data: siteSettingsData } = useSWR("/api/site-settings", fetcher); const { data: membershipSummary } = useSWR<{ summary?: { currentCredits?: number; }; } | null>("/api/user/membership-summary", async (url: string) => { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) return null; return response.json(); }); const siteLogo = siteSettingsData?.siteLogo; const siteLogoDark = siteSettingsData?.siteLogoDark; const siteName = siteSettingsData?.siteName || "SparkAI"; const pointsLabel = useMemo(() => { const rawValue = Number(membershipSummary?.summary?.currentCredits ?? 0); const normalized = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0; return normalized.toLocaleString("en-US"); }, [membershipSummary?.summary?.currentCredits]); const userAvatarFallback = useMemo(() => { const fromName = String(user?.name || "").trim(); if (fromName) return fromName.charAt(0).toUpperCase(); const fromEmail = String(user?.email || "").trim(); if (fromEmail) return fromEmail.charAt(0).toUpperCase(); return "U"; }, [user?.email, user?.name]); // Display logo logic (prefer dark mode logo since we are in dark theme) const displayLogo = siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo; // Effect hooks for data loading and cleanup useEffect(() => { console.log("CanvasModel3DPage mounted - v3.0 Dark Mode"); }, []); useEffect(() => { const html = document.documentElement; const body = document.body; const prevHtmlOverscrollX = html.style.overscrollBehaviorX; const prevBodyOverscrollX = body.style.overscrollBehaviorX; html.style.overscrollBehaviorX = "none"; body.style.overscrollBehaviorX = "none"; const preventHistorySwipe = (event: WheelEvent) => { if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; if (event.cancelable) { event.preventDefault(); } }; window.addEventListener("wheel", preventHistorySwipe, { passive: false }); return () => { window.removeEventListener("wheel", preventHistorySwipe); html.style.overscrollBehaviorX = prevHtmlOverscrollX; body.style.overscrollBehaviorX = prevBodyOverscrollX; }; }, []); const persistTaskRecord = useCallback( async (task: TaskRecord) => { if (!id) return; try { const response = await fetch(`/api/canvas/${id}/model3d-assets`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ asset: { taskId: task.taskId, status: task.status, mode: task.mode, prompt: task.prompt, modelVersion: task.modelVersion, progress: task.progress ?? null, modelUrl: task.modelUrl ?? null, previewImageUrl: task.previewImageUrl ?? null, error: task.error ?? null, sourceTaskId: task.sourceTaskId ?? null, exportFormat: task.exportFormat ?? null, createdAt: task.createdAt, }, }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload?.error || `HTTP ${response.status}`); } } catch (error) { console.error("[Model3DPage] 持久化 3D 资产失败:", error); } }, [id] ); useEffect(() => { if (!id) return; let cancelled = false; const migrateLegacyHistory = async () => { if (typeof window === "undefined") return [] as TaskRecord[]; const raw = window.localStorage.getItem(legacyHistoryStorageKey); if (!raw) return [] as TaskRecord[]; try { const parsed = JSON.parse(raw) as TaskRecord[]; if (!Array.isArray(parsed)) { window.localStorage.removeItem(legacyHistoryStorageKey); return [] as TaskRecord[]; } const normalized = parsed .filter((item) => item && typeof item.taskId === "string") .map((item) => normalizeTaskRecord(item)) .slice(0, TASK_HISTORY_LIMIT); for (const item of normalized) { await persistTaskRecord(item); } window.localStorage.removeItem(legacyHistoryStorageKey); return normalized; } catch { window.localStorage.removeItem(legacyHistoryStorageKey); return [] as TaskRecord[]; } }; const loadTaskHistory = async () => { try { const response = await fetch(`/api/canvas/${id}/model3d-assets`, { cache: "no-store", }); const payload = (await response.json().catch(() => ({}))) as { success?: boolean; error?: string; data?: TaskRecordApiEntity[]; }; if (!response.ok || payload?.success === false) { throw new Error(payload?.error || "加载 3D 资产失败"); } const list: TaskRecordApiEntity[] = Array.isArray(payload?.data) ? payload.data : []; let normalized = list .map((item) => mapApiEntityToTaskRecord(item)) .filter((item): item is TaskRecord => Boolean(item)) .slice(0, TASK_HISTORY_LIMIT); if (normalized.length === 0) { normalized = await migrateLegacyHistory(); } else if (typeof window !== "undefined") { window.localStorage.removeItem(legacyHistoryStorageKey); } if (cancelled) return; setTaskHistory(normalized); const pending = normalized.find( (item) => !FINAL_TASK_STATUS.has(item.status) ); setCurrentTask(pending || normalized[0] || null); } catch (error) { console.error("[Model3DPage] 加载 3D 资产库失败:", error); const migrated = await migrateLegacyHistory(); if (cancelled) return; setTaskHistory(migrated); const pending = migrated.find( (item) => !FINAL_TASK_STATUS.has(item.status) ); setCurrentTask(pending || migrated[0] || null); } }; void loadTaskHistory(); return () => { cancelled = true; }; }, [id, legacyHistoryStorageKey, persistTaskRecord]); useEffect(() => { if (!referenceImage) { if (referencePreviewUrl) { URL.revokeObjectURL(referencePreviewUrl); } setReferencePreviewUrl(null); return; } const objectUrl = URL.createObjectURL(referenceImage); setReferencePreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return objectUrl; }); return () => { URL.revokeObjectURL(objectUrl); }; }, [referenceImage]); useEffect(() => { multiViewSlotsRef.current = multiViewSlots; }, [multiViewSlots]); useEffect(() => { batchImageSlotsRef.current = batchImageSlots; }, [batchImageSlots]); useEffect(() => { return () => { for (const slot of multiViewSlotsRef.current) { if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } for (const slot of batchImageSlotsRef.current) { if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } }; }, []); const upsertTaskHistory = useCallback( (next: TaskRecord, options?: { persist?: boolean }) => { const normalized = normalizeTaskRecord(next); setTaskHistory((prev) => { const index = prev.findIndex((item) => item.taskId === normalized.taskId); if (index >= 0) { const updated = [...prev]; updated[index] = { ...updated[index], ...normalized }; return updated.slice(0, TASK_HISTORY_LIMIT); } return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT); }); if (options?.persist !== false) { void persistTaskRecord(normalized); } }, [persistTaskRecord] ); const effectiveLayerVisibility = useMemo(() => { if (!isolatedLayerId) return layerVisibility; const next: Record = {}; for (const layer of viewerLayers) { next[layer.id] = layer.id === isolatedLayerId && layerVisibility[layer.id] !== false; } return next; }, [isolatedLayerId, layerVisibility, viewerLayers]); const currentModelUrl = currentTask?.modelUrl || null; const currentPreviewImage = currentTask?.previewImageUrl || (mode === "image" ? referencePreviewUrl : null) || (mode === "multiview" ? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl : null) || (mode === "batch" ? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl : null) || null; // Polling logic useEffect(() => { if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) { return; } pollAttemptsRef.current = 0; let cancelled = false; const runPoll = async () => { if (cancelled) return; pollAttemptsRef.current += 1; const result = await model3DGenerationService.getTask(currentTask.taskId); if (!result.success || !result.data) { const failMessage = result.error || "任务查询失败"; const failedRecord: TaskRecord = { ...currentTask, status: "failed", error: failMessage, }; setCurrentTask(failedRecord); upsertTaskHistory(failedRecord); toast.error(failMessage); return; } const status = normalizeTaskStatus(result.data.status); const nextRecord: TaskRecord = { ...currentTask, status, progress: result.data.progress ?? null, modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null, previewImageUrl: result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null, error: status === "failed" ? result.data.errorMessage || "任务失败" : null, }; if (taskRecordChanged(currentTask, nextRecord)) { setCurrentTask(nextRecord); upsertTaskHistory(nextRecord); } if (status === "success") { toast.success(`${modeLabel(nextRecord.mode)}任务已完成`); if ( autoDownloadTaskId && autoDownloadTaskId === nextRecord.taskId && nextRecord.modelUrl ) { const link = document.createElement("a"); link.href = nextRecord.modelUrl; link.download = `tripo-model-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`; link.click(); setAutoDownloadTaskId(null); } } if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) { const timeoutRecord: TaskRecord = { ...nextRecord, status: "failed", error: "任务轮询超时,请稍后重试", }; setCurrentTask(timeoutRecord); upsertTaskHistory(timeoutRecord); toast.error("任务轮询超时,请稍后重试"); } }; void runPoll(); const timer = window.setInterval(() => { void runPoll(); }, 2000); return () => { cancelled = true; window.clearInterval(timer); }; }, [autoDownloadTaskId, currentTask, upsertTaskHistory]); // File handling functions const handleMultiViewFileChange = (index: number, file: File | null) => { setMultiViewSlots((prev) => { const next = [...prev]; const target = next[index]; if (!target) return prev; if (target.previewUrl) URL.revokeObjectURL(target.previewUrl); next[index] = { ...target, file, previewUrl: file ? URL.createObjectURL(file) : null, }; return next; }); }; const clearMultiViewSlot = (index: number) => { handleMultiViewFileChange(index, null); }; const setBatchFiles = (files: File[]) => { const nextFiles = files.slice(0, 10); setBatchImageSlots((prev) => { for (const slot of prev) { if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } return createBatchImageSlots().map((slot, index) => { const file = nextFiles[index] || null; return { ...slot, file, previewUrl: file ? URL.createObjectURL(file) : null, }; }); }); }; const handleBatchPickerChange = (files: FileList | null) => { const nextFiles = files ? Array.from(files) : []; setBatchFiles(nextFiles); }; const handleBatchFileChange = (index: number, file: File | null) => { setBatchImageSlots((prev) => { const next = [...prev]; const target = next[index]; if (!target) return prev; if (target.previewUrl) URL.revokeObjectURL(target.previewUrl); next[index] = { ...target, file, previewUrl: file ? URL.createObjectURL(file) : null, }; return next; }); }; const clearBatchSlot = (index: number) => { handleBatchFileChange(index, null); }; // Task submission functions const buildGenerationPayload = ( taskType: "text_to_model" | "image_to_model" | "multiview_to_model" ) => { const payload: Record = { taskType, prompt: prompt.trim(), negativePrompt: negativePrompt.trim(), modelVersion, faceLimit, texture, pbr, autoSize, quad, generateParts, smartLowPoly, geometryQuality, textureQuality, modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined, textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined, }; if (taskType === "image_to_model" || taskType === "multiview_to_model") { payload.textureAlignment = textureAlignment; payload.orientation = orientation; } return payload; }; const submitTask = async (params: { payload: Record; mode: TaskMode; promptText: string; sourceTaskId?: string | null; exportFormat?: string | null; }) => { const createResult = await model3DGenerationService.createTask( params.payload ); if (!createResult.success || !createResult.data?.taskId) { throw new Error(createResult.error || "创建任务失败"); } const newTask: TaskRecord = { taskId: createResult.data.taskId, status: normalizeTaskStatus(createResult.data.status), prompt: params.promptText, mode: params.mode, modelVersion, createdAt: Date.now(), sourceTaskId: params.sourceTaskId || null, exportFormat: params.exportFormat || null, }; setCurrentTask(newTask); upsertTaskHistory(newTask); return newTask; }; const getReadyTask = () => { if (!currentTask || currentTask.status !== "success") { toast.error("请先完成一个成功任务"); return null; } return currentTask; }; const submitDerivedTask = async (params: { payload: Record; mode: TaskMode; promptText: string; sourceTaskId: string; successMessage: string; }) => { setIsSubmitting(true); try { await submitTask({ payload: params.payload, mode: params.mode, promptText: params.promptText, sourceTaskId: params.sourceTaskId, }); toast.success(params.successMessage); } catch (error) { toast.error(error instanceof Error ? error.message : "提交任务失败"); } finally { setIsSubmitting(false); } }; const handleGenerate = async () => { const normalizedPrompt = prompt.trim(); if (mode === "text" && !normalizedPrompt) { toast.error("请输入提示词"); return; } if (mode === "image" && !referenceImage) { toast.error("图生3D请先上传参考图"); return; } const selectedMultiFiles = multiViewSlots .map((slot) => slot.file) .filter(Boolean) as File[]; const selectedBatchFiles = batchImageSlots .map((slot) => slot.file) .filter(Boolean) as File[]; if (mode === "multiview" && selectedMultiFiles.length < 2) { toast.error("多视图生成至少上传 2 张图片"); return; } if (mode === "batch" && selectedBatchFiles.length < 1) { toast.error("批量图生请至少上传 1 张图片"); return; } setIsSubmitting(true); setUploadingImage(false); try { if (mode === "text") { await submitTask({ payload: buildGenerationPayload("text_to_model"), mode: "text", promptText: normalizedPrompt, }); toast.success("已提交文生3D任务"); return; } if (mode === "image") { setUploadingImage(true); const uploadResult = await model3DGenerationService.uploadImage( referenceImage as File ); if (!uploadResult.success || !uploadResult.data) { throw new Error(uploadResult.error || "上传参考图失败"); } const payload = buildGenerationPayload("image_to_model"); if (uploadResult.data.imageToken) { payload.imageToken = uploadResult.data.imageToken; } else if (uploadResult.data.file) { payload.file = uploadResult.data.file; } else { throw new Error("参考图上传未返回可用 token"); } await submitTask({ payload, mode: "image", promptText: normalizedPrompt || "图生3D", }); toast.success("已提交图生3D任务"); return; } if (mode === "batch") { setUploadingImage(true); let successCount = 0; for (const file of selectedBatchFiles.slice(0, 10)) { const uploadResult = await model3DGenerationService.uploadImage(file); if (!uploadResult.success || !uploadResult.data) { throw new Error(uploadResult.error || `上传失败: ${file.name}`); } const payload = buildGenerationPayload("image_to_model"); if (uploadResult.data.imageToken) { payload.imageToken = uploadResult.data.imageToken; } else if (uploadResult.data.file) { payload.file = uploadResult.data.file; } else { throw new Error(`图片未返回可用 token: ${file.name}`); } await submitTask({ payload, mode: "batch", promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`, }); successCount += 1; } toast.success(`已提交 ${successCount} 个批量图生3D任务`); return; } setUploadingImage(true); const uploadResults = await Promise.all( selectedMultiFiles.map(async (file) => model3DGenerationService.uploadImage(file) ) ); const filesPayload: Array> = []; for (const result of uploadResults) { if (!result.success || !result.data) { throw new Error(result.error || "上传多视图图片失败"); } if (result.data.imageToken) { filesPayload.push({ type: "jpg", file_token: result.data.imageToken, }); } else if (result.data.file) { filesPayload.push(result.data.file); } } if (filesPayload.length < 2) { throw new Error("多视图图片上传失败,请重试"); } const payload = buildGenerationPayload("multiview_to_model"); payload.files = filesPayload; await submitTask({ payload, mode: "multiview", promptText: normalizedPrompt || "多视图3D", }); toast.success("已提交多视图3D任务"); } catch (error) { toast.error(error instanceof Error ? error.message : "创建任务失败"); } finally { setUploadingImage(false); setIsSubmitting(false); } }; const handleImportToCanvas = async () => { if (!currentModelUrl || !currentTask || currentTask.status !== "success") { toast.error("暂无可导入的 3D 模型"); return; } const viewportX = viewport.x || 0; const viewportY = viewport.y || 0; const viewportZoom = viewport.zoom || 1; const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280; const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720; const targetWidth = 380; const targetHeight = 300; const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2; 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, mode: currentTask.mode, sourceTaskId: currentTask.sourceTaskId, progress: currentTask.progress, triangles: viewerStats.triangles, vertices: viewerStats.vertices, meshCount: viewerStats.meshCount, }, }, connections: [], }); try { await canvasService.saveCanvasState(id, getCanvasState()); } catch (error) { console.error("[Model3DPage] 保存画布失败:", error); } toast.success("3D 模型已导入画布"); }; const handleDownloadCurrent = () => { if (!currentModelUrl) { toast.error("暂无可下载的 3D 模型"); return; } const link = document.createElement("a"); link.href = currentModelUrl; link.download = `tripo-model-${currentTask?.taskId || "result"}.${extractModelFormat( currentModelUrl )}`; link.click(); }; const handleConvertAndDownload = async () => { const readyTask = getReadyTask(); if (!readyTask) return; setIsSubmitting(true); try { const baseTaskId = readyTask.taskId; const payload: Record = { taskType: "convert_model", originalModelTaskId: baseTaskId, format: exportFormat, textureSize: 4096, textureFormat: "JPEG", }; const task = await submitTask({ payload, mode: "convert", promptText: `${modeLabel(readyTask.mode)} -> ${exportFormat}`, sourceTaskId: baseTaskId, exportFormat, }); setAutoDownloadTaskId(task.taskId); toast.success(`已提交${exportFormat}转换任务,完成后自动下载`); } catch (error) { toast.error(error instanceof Error ? error.message : "格式转换失败"); } finally { setIsSubmitting(false); } }; const handleRefineModel = async () => { const readyTask = getReadyTask(); if (!readyTask) return; setIsSubmitting(true); try { const payload: Record = { taskType: "refine_model", draftModelTaskId: readyTask.taskId, }; await submitTask({ payload, mode: "refine", promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, }); toast.success("已提交模型精修任务"); } catch (error) { toast.error(error instanceof Error ? error.message : "提交精修任务失败"); } finally { setIsSubmitting(false); } }; const handleTextureModel = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "texture_model", originalModelTaskId: readyTask.taskId, texture, pbr, textureQuality, textureAlignment, modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined, textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined, texturePromptText: texturePromptText.trim() || undefined, }, mode: "texture", promptText: `纹理重绘(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交纹理重绘任务", }); }; const handleStylizeModel = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "stylize_model", originalModelTaskId: readyTask.taskId, style: stylizeStyle, blockSize: Number.isFinite(stylizeBlockSize) ? stylizeBlockSize : undefined, }, mode: "stylize", promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交风格化任务", }); }; const handleRigCheck = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "animate_prerigcheck", originalModelTaskId: readyTask.taskId, }, mode: "rigcheck", promptText: `可绑定检测(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交可绑定检测任务", }); }; const handleRigModel = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "animate_rig", originalModelTaskId: readyTask.taskId, outFormat: rigFormat, rigType, spec: rigSpec, }, mode: "rig", promptText: `自动绑定 ${rigType}(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交自动绑定任务", }); }; const handleRetargetAnimation = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "animate_retarget", originalModelTaskId: readyTask.taskId, animation: retargetAnimation, outFormat: rigFormat, bakeAnimation: retargetBake, exportWithGeometry: retargetExportWithGeometry, animateInPlace: retargetInPlace, }, mode: "retarget", promptText: `动作 ${retargetAnimation}(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交动作重定向任务", }); }; const handleMeshSegmentation = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "mesh_segmentation", originalModelTaskId: readyTask.taskId, }, mode: "segment", promptText: `网格分割(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交网格分割任务", }); }; const handleMeshCompletion = async () => { const readyTask = getReadyTask(); if (!readyTask) return; const parts = completionPartNames .split(",") .map((item) => item.trim()) .filter(Boolean); await submitDerivedTask({ payload: { taskType: "mesh_completion", originalModelTaskId: readyTask.taskId, partNames: parts.length ? parts : undefined, }, mode: "complete", promptText: `网格补全(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交网格补全任务", }); }; const handleLowpolyOptimize = async () => { const readyTask = getReadyTask(); if (!readyTask) return; await submitDerivedTask({ payload: { taskType: "highpoly_to_lowpoly", originalModelTaskId: readyTask.taskId, faceLimit: Number.isFinite(lowpolyFaceLimit) ? lowpolyFaceLimit : undefined, quad: lowpolyQuad, bake: lowpolyBake, }, mode: "lowpoly", promptText: `低模优化(来源 ${readyTask.taskId.slice(0, 8)})`, sourceTaskId: readyTask.taskId, successMessage: "已提交低模优化任务", }); }; const handleSelectTask = (task: TaskRecord) => { setCurrentTask(task); if (task.status === "success" && task.modelUrl) { setViewerLayers([]); setLayerVisibility({}); setIsolatedLayerId(null); } }; const handleClearHistory = async () => { const prevTaskHistory = taskHistory; const prevCurrentTask = currentTask; setTaskHistory([]); setCurrentTask(null); try { const response = await fetch(`/api/canvas/${id}/model3d-assets`, { method: "DELETE", }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload?.success === false) { throw new Error(payload?.error || "清空资产库失败"); } if (typeof window !== "undefined") { window.localStorage.removeItem(legacyHistoryStorageKey); } toast.success("资产库已清空"); } catch (error) { setTaskHistory(prevTaskHistory); setCurrentTask(prevCurrentTask); toast.error(error instanceof Error ? error.message : "清空资产库失败"); } }; const handleLayersChange = useCallback((layers: Model3DLayer[]) => { setViewerLayers(layers); setLayerVisibility((prev) => { const next: Record = {}; for (const layer of layers) { next[layer.id] = prev[layer.id] !== false; } return next; }); setIsolatedLayerId((prev) => prev && layers.some((layer) => layer.id === prev) ? prev : null ); }, []); const importDisabled = !currentTask || currentTask.status !== "success" || !currentModelUrl; const activeStatusLabel = currentTask ? statusLabel(currentTask.status) : "未开始"; const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务"; return (
{/* Top Navigation Bar */}
{/* Logo & Brand */}
router.push("/")} > {displayLogo ? ( {siteName} ) : (
)} {siteName}
{/* Divider */}
{/* Nav Links */}
{/* DCC Bridge (Mock) */}
积分 {pointsLabel}
{/* User Profile */}
{user?.image ? ( {user.name ) : (
{userAvatarFallback}
)}
{/* 1. Left Nav Sidebar (Icon only) */} {/* 2. Left Tool Panel (Expandable Content) */}

{activeTab === "create" && "生成模型"} {activeTab === "edit" && "模型编辑"} {activeTab === "scene" && "场景管理"}

{activeTab === "create" && ( <>
{[ { id: "text", label: "文生", icon: PenLine }, { id: "image", label: "图生", icon: ImagePlus }, { id: "multiview", label: "多视", icon: Box }, { id: "batch", label: "批量", icon: Images }, ].map((m) => ( ))}