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 @@
-
-
-
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+ {{ 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 },
})
}
}