From ffd1d6bbe52001b333a76d715ff5d04e3e48e207 Mon Sep 17 00:00:00 2001 From: zhangxiaohua <827885272@qq.com> Date: Fri, 16 Jan 2026 11:07:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B93D=E6=A8=A1=E5=9E=8B=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/prisma/schema.prisma | 1 + backend/src/ai-3d/ai-3d.service.ts | 1 + frontend/src/views/model/ModelViewer.vue | 640 +++++++++++++++++- .../src/views/workbench/ai-3d/Generate.vue | 23 +- .../src/views/workbench/ai-3d/History.vue | 16 +- frontend/src/views/workbench/ai-3d/Index.vue | 16 +- 6 files changed, 657 insertions(+), 40 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e881413..601e1f7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1076,6 +1076,7 @@ model AI3DTask { userId Int @map("user_id") /// 用户ID(任务归属用户) inputType String @map("input_type") /// 输入类型:text | image inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL + generateType String @default("Normal") @map("generate_type") /// 生成类型:Normal(带纹理) | Geometry(白模) | LowPoly(低多边形) | Sketch(草图) status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL(单个结果,兼容旧数据) previewUrl String? @map("preview_url") @db.Text /// 预览图URL(单个结果,兼容旧数据) diff --git a/backend/src/ai-3d/ai-3d.service.ts b/backend/src/ai-3d/ai-3d.service.ts index 7c6be29..f130323 100644 --- a/backend/src/ai-3d/ai-3d.service.ts +++ b/backend/src/ai-3d/ai-3d.service.ts @@ -55,6 +55,7 @@ export class AI3DService { tenantId, inputType: dto.inputType, inputContent: dto.inputContent, + generateType: dto.generateType || 'Normal', status: 'pending', }, }); diff --git a/frontend/src/views/model/ModelViewer.vue b/frontend/src/views/model/ModelViewer.vue index 6a2f173..5fdec91 100644 --- a/frontend/src/views/model/ModelViewer.vue +++ b/frontend/src/views/model/ModelViewer.vue @@ -85,19 +85,21 @@ - -
-
- 场景设置 - - - -
+ +
+ +
+
+ 场景设置 + + + +
-
+
背景
@@ -326,11 +328,38 @@
- - + + +
+ + + {{ format.name }} + + + + + 下载 +
@@ -342,6 +371,57 @@ 滚轮缩放
+ + +
+ + + + +
+ +
+
+ +
+
+ + +
+
+
+
+
+ + + +
@@ -356,7 +436,11 @@ import { FullscreenExitOutlined, SettingOutlined, RightOutlined, + LeftOutlined, AppstoreOutlined, + DownloadOutlined, + LoadingOutlined, + SyncOutlined, } from "@ant-design/icons-vue" // @ts-ignore @@ -371,6 +455,13 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js" import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js" // @ts-ignore import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" +// @ts-ignore +import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js" +// @ts-ignore +import { OBJExporter } from "three/examples/jsm/exporters/OBJExporter.js" +// @ts-ignore +import { STLExporter } from "three/examples/jsm/exporters/STLExporter.js" +import { message } from "ant-design-vue" const router = useRouter() const route = useRoute() @@ -384,6 +475,166 @@ const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>( ) const settingsPanelOpen = ref(true) +// 多模型和渲染模式相关 +const modelUrls = ref([]) +const currentModelIndex = ref(0) +const currentRenderMode = ref("textured") +const autoRotate = ref(true) // 默认开启自动旋转 + +// 渲染模式配置(4种:素模、材质、法线、PBR) +const renderModes = [ + { + key: "clay", + label: "素模", + preview: "linear-gradient(135deg, #c9c9c9 0%, #e8e8e8 100%)", + }, + { + key: "textured", + label: "材质贴图", + preview: "linear-gradient(135deg, #8b7355 0%, #d4c4b0 100%)", + }, + { + key: "normal", + label: "法线贴图", + preview: "linear-gradient(135deg, #7b68ee 0%, #87ceeb 100%)", + }, + { + key: "pbr", + label: "PBR渲染", + preview: "linear-gradient(135deg, #ff6b9d 0%, #c44cff 50%, #6b5bff 100%)", + }, +] + +// 切换自动旋转 +const toggleAutoRotate = () => { + autoRotate.value = !autoRotate.value + if (controls) { + controls.autoRotate = autoRotate.value + controls.autoRotateSpeed = 2.0 + } +} + +// 下载格式配置 +const downloadFormats = [ + { key: "glb", name: "GLB" }, + { key: "gltf", name: "GLTF" }, + { key: "obj", name: "OBJ" }, + { key: "stl", name: "STL" }, +] + +// 下载状态 +const selectedFormat = ref("glb") +const downloadingFormat = ref(null) + +// 下载模型 +const handleDownload = async (format: string) => { + if (!model || downloadingFormat.value) return + + downloadingFormat.value = format + + try { + let blob: Blob + let filename = `model_${Date.now()}` + + // 恢复原始材质后再导出 + const currentMode = currentRenderMode.value + if (currentMode !== "textured") { + applyRenderMode("textured") + } + + switch (format) { + case "glb": + blob = await exportGLB() + filename += ".glb" + break + case "gltf": + blob = await exportGLTF() + filename += ".gltf" + break + case "obj": + blob = exportOBJ() + filename += ".obj" + break + case "stl": + blob = exportSTL() + filename += ".stl" + break + default: + throw new Error("不支持的格式") + } + + // 恢复渲染模式 + if (currentMode !== "textured") { + applyRenderMode(currentMode) + } + + // 触发下载 + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + message.success(`${format.toUpperCase()} 文件下载成功`) + } catch (err: any) { + console.error("导出失败:", err) + message.error(`导出失败: ${err.message}`) + } finally { + downloadingFormat.value = null + } +} + +// 导出 GLB +const exportGLB = (): Promise => { + return new Promise((resolve, reject) => { + const exporter = new GLTFExporter() + exporter.parse( + model!, + (result: ArrayBuffer) => { + resolve(new Blob([result], { type: "model/gltf-binary" })) + }, + (error: Error) => reject(error), + { binary: true } + ) + }) +} + +// 导出 GLTF +const exportGLTF = (): Promise => { + return new Promise((resolve, reject) => { + const exporter = new GLTFExporter() + exporter.parse( + model!, + (result: object) => { + const json = JSON.stringify(result, null, 2) + resolve(new Blob([json], { type: "application/json" })) + }, + (error: Error) => reject(error), + { binary: false } + ) + }) +} + +// 导出 OBJ +const exportOBJ = (): Blob => { + const exporter = new OBJExporter() + const result = exporter.parse(model!) + return new Blob([result], { type: "text/plain" }) +} + +// 导出 STL +const exportSTL = (): Blob => { + const exporter = new STLExporter() + const result = exporter.parse(model!, { binary: true }) + return new Blob([result], { type: "application/octet-stream" }) +} + +// 保存原始材质 +let originalMaterials: Map = new Map() + // 场景设置默认值 const defaultSettings = { backgroundColor: "#f5f5f5", @@ -433,30 +684,156 @@ let fillLight: THREE.DirectionalLight | null = null let spotLight: THREE.SpotLight | null = null let gridHelper: THREE.GridHelper | null = null -// 获取模型 URL - 支持刷新页面保持状态 +// 获取模型 URL - 支持刷新页面保持状态,支持多模型 const SESSION_KEY = "model-viewer-url" -const getModelUrl = (): string => { - // 优先从 query 获取 +const SESSION_KEY_URLS = "model-viewer-urls" +const SESSION_KEY_INDEX = "model-viewer-index" + +const initModelUrls = () => { + // 优先从 query 获取(支持多个URL,用逗号分隔) const queryUrl = route.query.url as string - if (queryUrl) { - // 保存到 sessionStorage,以便刷新页面时恢复 + const queryUrls = route.query.urls as string + const queryIndex = route.query.index as string + + if (queryUrls) { + // 多个URL(从query) + const urls = queryUrls.split(",").filter(Boolean) + sessionStorage.setItem(SESSION_KEY_URLS, JSON.stringify(urls)) + modelUrls.value = urls + // 设置初始索引 + if (queryIndex) { + currentModelIndex.value = Math.min(parseInt(queryIndex) || 0, urls.length - 1) + } + } else if (queryUrl) { + // 单个URL(从query) sessionStorage.setItem(SESSION_KEY, queryUrl) - return queryUrl + modelUrls.value = [queryUrl] + } else { + // 从 sessionStorage 恢复 + const storedUrls = sessionStorage.getItem(SESSION_KEY_URLS) + const storedUrl = sessionStorage.getItem(SESSION_KEY) + const storedIndex = sessionStorage.getItem(SESSION_KEY_INDEX) + + if (storedUrls) { + modelUrls.value = JSON.parse(storedUrls) + // 恢复索引 + if (storedIndex) { + currentModelIndex.value = Math.min( + parseInt(storedIndex) || 0, + modelUrls.value.length - 1 + ) + } + } else if (storedUrl) { + modelUrls.value = [storedUrl] + } } - // 如果 query 没有,尝试从 sessionStorage 恢复 - const storedUrl = sessionStorage.getItem(SESSION_KEY) - if (storedUrl) { - return storedUrl - } - return "" } -const modelUrl = ref(getModelUrl()) -console.log("模型查看器 - URL:", modelUrl.value) + +const modelUrl = ref("") +const updateCurrentModelUrl = () => { + modelUrl.value = modelUrls.value[currentModelIndex.value] || "" +} + +// 切换模型 +const switchModel = (direction: number) => { + const newIndex = currentModelIndex.value + direction + if (newIndex >= 0 && newIndex < modelUrls.value.length) { + currentModelIndex.value = newIndex + updateCurrentModelUrl() + // 重置渲染模式为默认 + currentRenderMode.value = "textured" + // 重置自动旋转 + autoRotate.value = false + if (controls) { + controls.autoRotate = false + } + // 重新加载模型 + loadModel() + } +} + +// 切换渲染模式 +const switchRenderMode = (mode: string) => { + if (currentRenderMode.value === mode || !model) return + currentRenderMode.value = mode + applyRenderMode(mode) +} + +// 应用渲染模式 +const applyRenderMode = (mode: string) => { + if (!model) return + + model.traverse((child: THREE.Object3D) => { + if (child instanceof THREE.Mesh) { + // 如果没保存过原始材质,先保存 + if (!originalMaterials.has(child)) { + originalMaterials.set(child, child.material) + } + + const originalMaterial = originalMaterials.get(child) + + switch (mode) { + case "wireframe": + // 线框模式 + child.material = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + wireframe: true, + }) + break + + case "clay": + // 素模 - 灰色无贴图 + child.material = new THREE.MeshStandardMaterial({ + color: 0xcccccc, + metalness: 0.1, + roughness: 0.8, + }) + break + + case "textured": + // 恢复原始材质 + if (originalMaterial) { + child.material = originalMaterial + } + break + + case "normal": + // 法线贴图可视化 + child.material = new THREE.MeshNormalMaterial() + break + + case "pbr": + // PBR渲染 - 增强金属感 + if (originalMaterial && !Array.isArray(originalMaterial)) { + const mat = originalMaterial as THREE.MeshStandardMaterial + child.material = new THREE.MeshStandardMaterial({ + color: mat.color || 0xffffff, + map: mat.map || null, + metalness: 0.8, + roughness: 0.2, + envMapIntensity: 1.5, + }) + } else { + child.material = new THREE.MeshStandardMaterial({ + color: 0xffffff, + metalness: 0.8, + roughness: 0.2, + }) + } + break + } + } + }) +} + +console.log("模型查看器初始化") // 返回上一页 const handleBack = () => { - // 清除 sessionStorage 中的 URL,防止下次从其他入口进入时使用旧的 URL + // 清除 sessionStorage 中的 URL 和索引,防止下次从其他入口进入时使用旧数据 sessionStorage.removeItem(SESSION_KEY) + sessionStorage.removeItem(SESSION_KEY_URLS) + sessionStorage.removeItem(SESSION_KEY_INDEX) // 检查是否有历史记录可以返回 // 如果是从新窗口打开的(无历史),则关闭窗口 if (window.history.length <= 1) { @@ -647,6 +1024,9 @@ const initScene = () => { controls.enablePan = true controls.panSpeed = 1 controls.rotateSpeed = 1 + // 自动旋转 + controls.autoRotate = autoRotate.value + controls.autoRotateSpeed = 2.0 // 添加半球光 - 更自然的环境光 const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6) @@ -1077,6 +1457,9 @@ const disposeScene = () => { mainLight = null fillLight = null gridHelper = null + + // 清理材质缓存 + originalMaterials.clear() } // 重试 @@ -1089,6 +1472,10 @@ onMounted(() => { window.addEventListener("resize", handleResize) document.addEventListener("fullscreenchange", handleFullscreenChange) + // 初始化多模型URL + initModelUrls() + updateCurrentModelUrl() + initScene() loadModel() }) @@ -1526,22 +1913,32 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%); } // ========================================== -// Scene Settings (Right Side) +// Right Panels Container // ========================================== -.scene-settings { +.right-panels { position: absolute; top: 84px; right: 20px; width: 280px; max-height: calc(100% - 104px); + display: flex; + flex-direction: column; + gap: 20px; + z-index: 10; +} + +// ========================================== +// Scene Settings +// ========================================== +.scene-settings { background: rgba($surface, 0.85); backdrop-filter: blur(20px); border: 1px solid rgba($primary, 0.3); border-radius: 12px; - z-index: 10; overflow: hidden; display: flex; flex-direction: column; + max-height: calc(100vh - 200px); } .settings-header { @@ -1720,6 +2117,67 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%); } } +// ========================================== +// Download Panel (独立面板,场景设置下方20px) +// ========================================== +.download-panel { + display: flex; + gap: 10px; + padding: 14px 16px; + background: rgba($surface, 0.85); + backdrop-filter: blur(20px); + border: 1px solid rgba($primary, 0.3); + border-radius: 12px; + flex-shrink: 0; + + .format-select { + flex: 1; + + :deep(.ant-select-selector) { + background: rgba(40, 40, 55, 0.95) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 8px !important; + color: #fff !important; + height: 38px !important; + + .ant-select-selection-item { + line-height: 36px !important; + font-weight: 500; + font-size: 13px; + } + } + + :deep(.ant-select-arrow) { + color: rgba(255, 255, 255, 0.5); + } + + &:hover :deep(.ant-select-selector) { + border-color: rgba(255, 255, 255, 0.25) !important; + } + + &.ant-select-focused :deep(.ant-select-selector) { + border-color: $primary !important; + box-shadow: none !important; + } + } + + .download-btn { + height: 38px; + padding: 0 18px; + border-radius: 8px; + font-weight: 500; + font-size: 13px; + background: $gradient-primary !important; + border: none !important; + box-shadow: 0 2px 8px rgba($primary, 0.3); + + &:hover { + filter: brightness(1.1); + box-shadow: 0 4px 12px rgba($primary, 0.4); + } + } +} + // Select 样式 :deep(.ant-select) { .ant-select-selector { @@ -1790,4 +2248,118 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%); } } } + +// ========================================== +// Bottom Controls (底部控制栏容器) +// ========================================== +.bottom-controls { + position: absolute; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + z-index: 15; +} + +// ========================================== +// Model Navigation Buttons (左右箭头) +// ========================================== +.model-nav-btn { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(40, 40, 50, 0.9); + backdrop-filter: blur(10px); + border: none; + border-radius: 50%; + color: rgba(255, 255, 255, 0.8); + font-size: 16px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgba(60, 60, 70, 0.95); + color: #fff; + transform: scale(1.05); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +// ========================================== +// Render Mode Bar (渲染模式选择器) +// ========================================== +.render-mode-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(40, 40, 50, 0.9); + backdrop-filter: blur(20px); + border-radius: 30px; +} + +.mode-item { + cursor: pointer; + transition: all 0.2s ease; + position: relative; + + &:hover { + transform: scale(1.1); + + &::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 11px; + white-space: nowrap; + border-radius: 4px; + margin-bottom: 8px; + pointer-events: none; + } + } + + &.active { + .mode-icon { + box-shadow: 0 0 0 2px rgba($primary, 0.6); + } + + .auto-rotate-icon { + background: rgba($primary, 0.3); + } + } + + .mode-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .auto-rotate-icon { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + font-size: 16px; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + } +} diff --git a/frontend/src/views/workbench/ai-3d/Generate.vue b/frontend/src/views/workbench/ai-3d/Generate.vue index 07e8a07..e3f2167 100644 --- a/frontend/src/views/workbench/ai-3d/Generate.vue +++ b/frontend/src/views/workbench/ai-3d/Generate.vue @@ -262,11 +262,30 @@ const handleCardClick = (index: number) => { } if (card.status === "completed" && card.resultUrl) { - // Navigate to model viewer (使用路由导航,保持全屏模式) + // 收集所有有效的模型URLs + const allResultUrls = modelCards.value + .filter((c) => c.status === "completed" && c.resultUrl) + .map((c) => c.resultUrl) + + // 存储到 sessionStorage(避免URL过长) + if (allResultUrls.length > 1) { + sessionStorage.setItem( + "model-viewer-urls", + JSON.stringify(allResultUrls) + ) + sessionStorage.setItem("model-viewer-index", String(index)) + // 清除单URL存储 + sessionStorage.removeItem("model-viewer-url") + } else { + sessionStorage.setItem("model-viewer-url", card.resultUrl) + sessionStorage.removeItem("model-viewer-urls") + sessionStorage.removeItem("model-viewer-index") + } + + // Navigate to model viewer const tenantCode = route.params.tenantCode as string router.push({ path: `/${tenantCode}/workbench/model-viewer`, - query: { url: card.resultUrl }, }) } } diff --git a/frontend/src/views/workbench/ai-3d/History.vue b/frontend/src/views/workbench/ai-3d/History.vue index e21c605..48cda00 100644 --- a/frontend/src/views/workbench/ai-3d/History.vue +++ b/frontend/src/views/workbench/ai-3d/History.vue @@ -294,11 +294,23 @@ const handleViewTask = (task: AI3DTask) => { // 预览3D模型 const handlePreview = (task: AI3DTask) => { - if (task.resultUrl) { + if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) { const tenantCode = route.params.tenantCode as string + const urls = task.resultUrls || [task.resultUrl] + + // 存储到 sessionStorage(支持多模型) + if (urls.length > 1) { + sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls)) + sessionStorage.setItem("model-viewer-index", "0") + sessionStorage.removeItem("model-viewer-url") + } else { + sessionStorage.setItem("model-viewer-url", urls[0] || "") + sessionStorage.removeItem("model-viewer-urls") + sessionStorage.removeItem("model-viewer-index") + } + router.push({ path: `/${tenantCode}/workbench/model-viewer`, - query: { url: task.resultUrl }, }) } } diff --git a/frontend/src/views/workbench/ai-3d/Index.vue b/frontend/src/views/workbench/ai-3d/Index.vue index d731ca6..e374be4 100644 --- a/frontend/src/views/workbench/ai-3d/Index.vue +++ b/frontend/src/views/workbench/ai-3d/Index.vue @@ -612,11 +612,23 @@ const stopPolling = () => { // 预览3D模型 const handlePreview = (task: AI3DTask) => { - if (task.resultUrl) { + if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) { const tenantCode = route.params.tenantCode as string + const urls = task.resultUrls || [task.resultUrl] + + // 存储到 sessionStorage(支持多模型) + if (urls.length > 1) { + sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls)) + sessionStorage.setItem("model-viewer-index", "0") + sessionStorage.removeItem("model-viewer-url") + } else { + sessionStorage.setItem("model-viewer-url", urls[0] || "") + sessionStorage.removeItem("model-viewer-urls") + sessionStorage.removeItem("model-viewer-index") + } + router.push({ path: `/${tenantCode}/workbench/model-viewer`, - query: { url: task.resultUrl }, }) } }