diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 9acee3123..25ab37d3b 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { formatDistanceToNow } from "date-fns"; +import { zhCN } from "date-fns/locale"; import { ArrowLeft, + Bone, Box, Boxes, ChevronDown, @@ -11,39 +12,77 @@ import { Download, Eye, EyeOff, + FileBox, + Grid3X3, + History, ImagePlus, Images, Layers3, Loader2, + Maximize, + Palette, PenLine, + Play, PlusCircle, RefreshCw, + RotateCw, + Settings2, Sparkles, + Sun, WandSparkles, X, } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +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 { + 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 { + 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 { - Model3DViewer, - type Model3DLayer, - type Model3DStats, - type Model3DViewerSettings, -} from "@/components/canvas/model3d/Model3DViewer"; -import { canvasSelectors, useCanvasStore } from "@/store/canvas"; -import { canvasService, model3DGenerationService, type Model3DTaskStatus } from "@/services/canvas"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } 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 = @@ -89,7 +128,7 @@ const MODEL_VERSION_OPTIONS = [ "v1.4-20240625", ] as const; -const ENV_PRESET_OPTIONS: Array = [ +const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [ "city", "sunset", "studio", @@ -102,7 +141,14 @@ const ENV_PRESET_OPTIONS: Array = [ "lobby", ]; -const EXPORT_FORMAT_OPTIONS = ["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"] as const; +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", @@ -156,9 +202,15 @@ function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus { function formatBalance(data: Record | undefined): string | null { if (!data || typeof data !== "object") return null; - const candidates = [data.balance, data.available_balance, data.credits, data.amount]; + const candidates = [ + data.balance, + data.available_balance, + data.credits, + data.amount, + ]; for (const value of candidates) { - if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "number" && Number.isFinite(value)) + return String(value); if (typeof value === "string" && value.trim()) return value.trim(); } return null; @@ -266,30 +318,40 @@ export default function CanvasModel3DPage() { 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(120000); + 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 [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 [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 [referencePreviewUrl, setReferencePreviewUrl] = useState( + null + ); + const [multiViewSlots, setMultiViewSlots] = useState(() => + createMultiViewSlots() + ); const [batchImageSlots, setBatchImageSlots] = useState(() => createBatchImageSlots() ); @@ -297,29 +359,13 @@ export default function CanvasModel3DPage() { const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [balanceLabel, setBalanceLabel] = useState(null); - const [autoDownloadTaskId, setAutoDownloadTaskId] = useState(null); + const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( + null + ); const [advancedOpen, setAdvancedOpen] = useState(false); - const [viewerOpen, setViewerOpen] = useState(true); - const [postOpen, setPostOpen] = useState(true); - 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(30000); - const [lowpolyQuad, setLowpolyQuad] = useState(false); - const [lowpolyBake, setLowpolyBake] = useState(true); + // Viewer Settings const [viewerSettings, setViewerSettings] = useState({ envPreset: "city", lightIntensity: 1, @@ -333,12 +379,40 @@ export default function CanvasModel3DPage() { meshCount: 0, }); const [viewerLayers, setViewerLayers] = useState([]); - const [layerVisibility, setLayerVisibility] = 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); + const pollAttemptsRef = useRef(0); const multiViewSlotsRef = useRef([]); const batchImageSlotsRef = useRef([]); + // Effect hooks for data loading and cleanup useEffect(() => { const loadBalance = async () => { const result = await model3DGenerationService.getBalance(); @@ -360,7 +434,9 @@ export default function CanvasModel3DPage() { .filter((item) => item && typeof item.taskId === "string") .slice(0, TASK_HISTORY_LIMIT); setTaskHistory(normalized); - const pending = normalized.find((item) => !FINAL_TASK_STATUS.has(item.status)); + const pending = normalized.find( + (item) => !FINAL_TASK_STATUS.has(item.status) + ); setCurrentTask(pending || normalized[0] || null); } catch { window.localStorage.removeItem(historyStorageKey); @@ -369,7 +445,10 @@ export default function CanvasModel3DPage() { useEffect(() => { if (typeof window === "undefined") return; - window.localStorage.setItem(historyStorageKey, JSON.stringify(taskHistory.slice(0, TASK_HISTORY_LIMIT))); + window.localStorage.setItem( + historyStorageKey, + JSON.stringify(taskHistory.slice(0, TASK_HISTORY_LIMIT)) + ); }, [historyStorageKey, taskHistory]); useEffect(() => { @@ -401,14 +480,10 @@ export default function CanvasModel3DPage() { useEffect(() => { return () => { for (const slot of multiViewSlotsRef.current) { - if (slot.previewUrl) { - URL.revokeObjectURL(slot.previewUrl); - } + if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } for (const slot of batchImageSlotsRef.current) { - if (slot.previewUrl) { - URL.revokeObjectURL(slot.previewUrl); - } + if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } }; }, []); @@ -429,7 +504,8 @@ export default function CanvasModel3DPage() { if (!isolatedLayerId) return layerVisibility; const next: Record = {}; for (const layer of viewerLayers) { - next[layer.id] = layer.id === isolatedLayerId && layerVisibility[layer.id] !== false; + next[layer.id] = + layer.id === isolatedLayerId && layerVisibility[layer.id] !== false; } return next; }, [isolatedLayerId, layerVisibility, viewerLayers]); @@ -438,10 +514,15 @@ export default function CanvasModel3DPage() { 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) || + (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; @@ -474,15 +555,21 @@ export default function CanvasModel3DPage() { 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, + previewImageUrl: + result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null, + error: + status === "failed" ? result.data.errorMessage || "任务失败" : null, }; setCurrentTask(nextRecord); upsertTaskHistory(nextRecord); if (status === "success") { toast.success(`${modeLabel(nextRecord.mode)}任务已完成`); - if (autoDownloadTaskId && autoDownloadTaskId === nextRecord.taskId && nextRecord.modelUrl) { + 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)}`; @@ -514,12 +601,12 @@ export default function CanvasModel3DPage() { }; }, [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, @@ -538,9 +625,7 @@ export default function CanvasModel3DPage() { const nextFiles = files.slice(0, 10); setBatchImageSlots((prev) => { for (const slot of prev) { - if (slot.previewUrl) { - URL.revokeObjectURL(slot.previewUrl); - } + if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } return createBatchImageSlots().map((slot, index) => { const file = nextFiles[index] || null; @@ -563,7 +648,6 @@ export default function CanvasModel3DPage() { const next = [...prev]; const target = next[index]; if (!target) return prev; - if (target.previewUrl) URL.revokeObjectURL(target.previewUrl); next[index] = { ...target, @@ -578,7 +662,10 @@ export default function CanvasModel3DPage() { handleBatchFileChange(index, null); }; - const buildGenerationPayload = (taskType: "text_to_model" | "image_to_model" | "multiview_to_model") => { + // Task submission functions + const buildGenerationPayload = ( + taskType: "text_to_model" | "image_to_model" | "multiview_to_model" + ) => { const payload: Record = { taskType, prompt: prompt.trim(), @@ -601,7 +688,6 @@ export default function CanvasModel3DPage() { payload.textureAlignment = textureAlignment; payload.orientation = orientation; } - return payload; }; @@ -612,7 +698,9 @@ export default function CanvasModel3DPage() { sourceTaskId?: string | null; exportFormat?: string | null; }) => { - const createResult = await model3DGenerationService.createTask(params.payload); + const createResult = await model3DGenerationService.createTask( + params.payload + ); if (!createResult.success || !createResult.data?.taskId) { throw new Error(createResult.error || "创建任务失败"); } @@ -673,8 +761,12 @@ export default function CanvasModel3DPage() { 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[]; + 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; @@ -699,7 +791,9 @@ export default function CanvasModel3DPage() { if (mode === "image") { setUploadingImage(true); - const uploadResult = await model3DGenerationService.uploadImage(referenceImage as File); + const uploadResult = await model3DGenerationService.uploadImage( + referenceImage as File + ); if (!uploadResult.success || !uploadResult.data) { throw new Error(uploadResult.error || "上传参考图失败"); } @@ -749,7 +843,9 @@ export default function CanvasModel3DPage() { setUploadingImage(true); const uploadResults = await Promise.all( - selectedMultiFiles.map(async (file) => model3DGenerationService.uploadImage(file)) + selectedMultiFiles.map(async (file) => + model3DGenerationService.uploadImage(file) + ) ); const filesPayload: Array> = []; for (const result of uploadResults) { @@ -794,11 +890,13 @@ export default function CanvasModel3DPage() { 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 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; + const y = + (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2; addNode({ type: "model3d", @@ -847,9 +945,7 @@ export default function CanvasModel3DPage() { const handleConvertAndDownload = async () => { const readyTask = getReadyTask(); - if (!readyTask) { - return; - } + if (!readyTask) return; setIsSubmitting(true); try { @@ -879,9 +975,7 @@ export default function CanvasModel3DPage() { const handleRefineModel = async () => { const readyTask = getReadyTask(); - if (!readyTask) { - return; - } + if (!readyTask) return; setIsSubmitting(true); try { @@ -935,7 +1029,9 @@ export default function CanvasModel3DPage() { taskType: "stylize_model", originalModelTaskId: readyTask.taskId, style: stylizeStyle, - blockSize: Number.isFinite(stylizeBlockSize) ? stylizeBlockSize : undefined, + blockSize: Number.isFinite(stylizeBlockSize) + ? stylizeBlockSize + : undefined, }, mode: "stylize", promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`, @@ -1046,7 +1142,9 @@ export default function CanvasModel3DPage() { payload: { taskType: "highpoly_to_lowpoly", originalModelTaskId: readyTask.taskId, - faceLimit: Number.isFinite(lowpolyFaceLimit) ? lowpolyFaceLimit : undefined, + faceLimit: Number.isFinite(lowpolyFaceLimit) + ? lowpolyFaceLimit + : undefined, quad: lowpolyQuad, bake: lowpolyBake, }, @@ -1083,990 +1181,766 @@ export default function CanvasModel3DPage() { } return next; }); - setIsolatedLayerId((prev) => (prev && layers.some((layer) => layer.id === prev) ? prev : null)); + 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 importDisabled = + !currentTask || currentTask.status !== "success" || !currentModelUrl; + const activeStatusLabel = currentTask + ? statusLabel(currentTask.status) + : "未开始"; const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务"; return ( -
-
-
- -
-

3D 生成工作台

-

- Tripo3D 文生模型 / 图生模型 / 多视图模型 · 导入无限画布 -

-
-
-
-
- 额度: {balanceLabel || "--"} -
- -
-
+
+ {/* 1. Background Viewer */} +
+ -
- - - 生成参数 - 补齐多视图与高级参数 - - -
-
+ {/* Viewer Toolbar (Bottom Center) */} +
+ + + + + 网格 + + + + + + 自动旋转 + + + + + - - -
-
- 图生 - 多视图 - 批量 - 文生 -
-
+ + 线框模式 + -
- -