修改3D模型页面
This commit is contained in:
parent
252ba92266
commit
ffd1d6bbe5
@ -1076,6 +1076,7 @@ model AI3DTask {
|
|||||||
userId Int @map("user_id") /// 用户ID(任务归属用户)
|
userId Int @map("user_id") /// 用户ID(任务归属用户)
|
||||||
inputType String @map("input_type") /// 输入类型:text | image
|
inputType String @map("input_type") /// 输入类型:text | image
|
||||||
inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL
|
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
|
status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout
|
||||||
resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL(单个结果,兼容旧数据)
|
resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL(单个结果,兼容旧数据)
|
||||||
previewUrl String? @map("preview_url") @db.Text /// 预览图URL(单个结果,兼容旧数据)
|
previewUrl String? @map("preview_url") @db.Text /// 预览图URL(单个结果,兼容旧数据)
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export class AI3DService {
|
|||||||
tenantId,
|
tenantId,
|
||||||
inputType: dto.inputType,
|
inputType: dto.inputType,
|
||||||
inputContent: dto.inputContent,
|
inputContent: dto.inputContent,
|
||||||
|
generateType: dto.generateType || 'Normal',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -85,8 +85,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scene Settings Panel (右侧) -->
|
<!-- 右侧面板容器 -->
|
||||||
<div v-if="!loading && !error" class="scene-settings">
|
<div v-if="!loading && !error" class="right-panels">
|
||||||
|
<!-- Scene Settings Panel -->
|
||||||
|
<div class="scene-settings">
|
||||||
<div
|
<div
|
||||||
class="settings-header"
|
class="settings-header"
|
||||||
@click="settingsPanelOpen = !settingsPanelOpen"
|
@click="settingsPanelOpen = !settingsPanelOpen"
|
||||||
@ -334,6 +336,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 下载区域 - 独立面板,在场景设置下方20px -->
|
||||||
|
<div class="download-panel">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedFormat"
|
||||||
|
class="format-select"
|
||||||
|
:disabled="downloadingFormat !== null"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="format in downloadFormats"
|
||||||
|
:key="format.key"
|
||||||
|
:value="format.key"
|
||||||
|
>
|
||||||
|
{{ format.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
class="download-btn"
|
||||||
|
:loading="downloadingFormat !== null"
|
||||||
|
@click="handleDownload(selectedFormat)"
|
||||||
|
>
|
||||||
|
<template #icon><DownloadOutlined /></template>
|
||||||
|
下载
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Controls Info -->
|
<!-- Controls Info -->
|
||||||
<div v-if="!loading && !error" class="controls-info">
|
<div v-if="!loading && !error" class="controls-info">
|
||||||
<div class="control-hint">
|
<div class="control-hint">
|
||||||
@ -342,6 +371,57 @@
|
|||||||
<span class="hint-item"><span class="key">滚轮</span>缩放</span>
|
<span class="hint-item"><span class="key">滚轮</span>缩放</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部控制栏 -->
|
||||||
|
<div v-if="!loading && !error" class="bottom-controls">
|
||||||
|
<!-- 左箭头 - 切换模型 -->
|
||||||
|
<button
|
||||||
|
v-if="modelUrls.length > 1"
|
||||||
|
class="model-nav-btn"
|
||||||
|
:disabled="currentModelIndex === 0"
|
||||||
|
@click="switchModel(-1)"
|
||||||
|
>
|
||||||
|
<LeftOutlined />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 渲染模式选择器 -->
|
||||||
|
<div class="render-mode-bar">
|
||||||
|
<!-- 自动旋转 -->
|
||||||
|
<div
|
||||||
|
class="mode-item"
|
||||||
|
:class="{ active: autoRotate }"
|
||||||
|
title="自动旋转"
|
||||||
|
@click="toggleAutoRotate"
|
||||||
|
>
|
||||||
|
<div class="mode-icon auto-rotate-icon">
|
||||||
|
<SyncOutlined :spin="autoRotate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 渲染模式 -->
|
||||||
|
<div
|
||||||
|
v-for="mode in renderModes"
|
||||||
|
:key="mode.key"
|
||||||
|
class="mode-item"
|
||||||
|
:class="{ active: currentRenderMode === mode.key }"
|
||||||
|
:title="mode.label"
|
||||||
|
@click="switchRenderMode(mode.key)"
|
||||||
|
>
|
||||||
|
<div class="mode-icon" :style="{ background: mode.preview }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右箭头 - 切换模型 -->
|
||||||
|
<button
|
||||||
|
v-if="modelUrls.length > 1"
|
||||||
|
class="model-nav-btn"
|
||||||
|
:disabled="currentModelIndex >= modelUrls.length - 1"
|
||||||
|
@click="switchModel(1)"
|
||||||
|
>
|
||||||
|
<RightOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -356,7 +436,11 @@ import {
|
|||||||
FullscreenExitOutlined,
|
FullscreenExitOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
|
LeftOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
SyncOutlined,
|
||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -371,6 +455,13 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
|
|||||||
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
|
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
|
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 router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -384,6 +475,166 @@ const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(
|
|||||||
)
|
)
|
||||||
const settingsPanelOpen = ref(true)
|
const settingsPanelOpen = ref(true)
|
||||||
|
|
||||||
|
// 多模型和渲染模式相关
|
||||||
|
const modelUrls = ref<string[]>([])
|
||||||
|
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<string | null>(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<Blob> => {
|
||||||
|
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<Blob> => {
|
||||||
|
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<THREE.Mesh, THREE.Material | THREE.Material[]> = new Map()
|
||||||
|
|
||||||
// 场景设置默认值
|
// 场景设置默认值
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
backgroundColor: "#f5f5f5",
|
backgroundColor: "#f5f5f5",
|
||||||
@ -433,30 +684,156 @@ let fillLight: THREE.DirectionalLight | null = null
|
|||||||
let spotLight: THREE.SpotLight | null = null
|
let spotLight: THREE.SpotLight | null = null
|
||||||
let gridHelper: THREE.GridHelper | null = null
|
let gridHelper: THREE.GridHelper | null = null
|
||||||
|
|
||||||
// 获取模型 URL - 支持刷新页面保持状态
|
// 获取模型 URL - 支持刷新页面保持状态,支持多模型
|
||||||
const SESSION_KEY = "model-viewer-url"
|
const SESSION_KEY = "model-viewer-url"
|
||||||
const getModelUrl = (): string => {
|
const SESSION_KEY_URLS = "model-viewer-urls"
|
||||||
// 优先从 query 获取
|
const SESSION_KEY_INDEX = "model-viewer-index"
|
||||||
|
|
||||||
|
const initModelUrls = () => {
|
||||||
|
// 优先从 query 获取(支持多个URL,用逗号分隔)
|
||||||
const queryUrl = route.query.url as string
|
const queryUrl = route.query.url as string
|
||||||
if (queryUrl) {
|
const queryUrls = route.query.urls as string
|
||||||
// 保存到 sessionStorage,以便刷新页面时恢复
|
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)
|
sessionStorage.setItem(SESSION_KEY, queryUrl)
|
||||||
return queryUrl
|
modelUrls.value = [queryUrl]
|
||||||
}
|
} else {
|
||||||
// 如果 query 没有,尝试从 sessionStorage 恢复
|
// 从 sessionStorage 恢复
|
||||||
|
const storedUrls = sessionStorage.getItem(SESSION_KEY_URLS)
|
||||||
const storedUrl = sessionStorage.getItem(SESSION_KEY)
|
const storedUrl = sessionStorage.getItem(SESSION_KEY)
|
||||||
if (storedUrl) {
|
const storedIndex = sessionStorage.getItem(SESSION_KEY_INDEX)
|
||||||
return storedUrl
|
|
||||||
|
if (storedUrls) {
|
||||||
|
modelUrls.value = JSON.parse(storedUrls)
|
||||||
|
// 恢复索引
|
||||||
|
if (storedIndex) {
|
||||||
|
currentModelIndex.value = Math.min(
|
||||||
|
parseInt(storedIndex) || 0,
|
||||||
|
modelUrls.value.length - 1
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return ""
|
} else if (storedUrl) {
|
||||||
|
modelUrls.value = [storedUrl]
|
||||||
}
|
}
|
||||||
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 = () => {
|
const handleBack = () => {
|
||||||
// 清除 sessionStorage 中的 URL,防止下次从其他入口进入时使用旧的 URL
|
// 清除 sessionStorage 中的 URL 和索引,防止下次从其他入口进入时使用旧数据
|
||||||
sessionStorage.removeItem(SESSION_KEY)
|
sessionStorage.removeItem(SESSION_KEY)
|
||||||
|
sessionStorage.removeItem(SESSION_KEY_URLS)
|
||||||
|
sessionStorage.removeItem(SESSION_KEY_INDEX)
|
||||||
// 检查是否有历史记录可以返回
|
// 检查是否有历史记录可以返回
|
||||||
// 如果是从新窗口打开的(无历史),则关闭窗口
|
// 如果是从新窗口打开的(无历史),则关闭窗口
|
||||||
if (window.history.length <= 1) {
|
if (window.history.length <= 1) {
|
||||||
@ -647,6 +1024,9 @@ const initScene = () => {
|
|||||||
controls.enablePan = true
|
controls.enablePan = true
|
||||||
controls.panSpeed = 1
|
controls.panSpeed = 1
|
||||||
controls.rotateSpeed = 1
|
controls.rotateSpeed = 1
|
||||||
|
// 自动旋转
|
||||||
|
controls.autoRotate = autoRotate.value
|
||||||
|
controls.autoRotateSpeed = 2.0
|
||||||
|
|
||||||
// 添加半球光 - 更自然的环境光
|
// 添加半球光 - 更自然的环境光
|
||||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
|
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
|
||||||
@ -1077,6 +1457,9 @@ const disposeScene = () => {
|
|||||||
mainLight = null
|
mainLight = null
|
||||||
fillLight = null
|
fillLight = null
|
||||||
gridHelper = null
|
gridHelper = null
|
||||||
|
|
||||||
|
// 清理材质缓存
|
||||||
|
originalMaterials.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重试
|
// 重试
|
||||||
@ -1089,6 +1472,10 @@ onMounted(() => {
|
|||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
||||||
|
|
||||||
|
// 初始化多模型URL
|
||||||
|
initModelUrls()
|
||||||
|
updateCurrentModelUrl()
|
||||||
|
|
||||||
initScene()
|
initScene()
|
||||||
loadModel()
|
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;
|
position: absolute;
|
||||||
top: 84px;
|
top: 84px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
max-height: calc(100% - 104px);
|
max-height: calc(100% - 104px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Scene Settings
|
||||||
|
// ==========================================
|
||||||
|
.scene-settings {
|
||||||
background: rgba($surface, 0.85);
|
background: rgba($surface, 0.85);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba($primary, 0.3);
|
border: 1px solid rgba($primary, 0.3);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
z-index: 10;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.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 样式
|
// Select 样式
|
||||||
:deep(.ant-select) {
|
:deep(.ant-select) {
|
||||||
.ant-select-selector {
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -262,11 +262,30 @@ const handleCardClick = (index: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (card.status === "completed" && card.resultUrl) {
|
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
|
const tenantCode = route.params.tenantCode as string
|
||||||
router.push({
|
router.push({
|
||||||
path: `/${tenantCode}/workbench/model-viewer`,
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
query: { url: card.resultUrl },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -294,11 +294,23 @@ const handleViewTask = (task: AI3DTask) => {
|
|||||||
|
|
||||||
// 预览3D模型
|
// 预览3D模型
|
||||||
const handlePreview = (task: AI3DTask) => {
|
const handlePreview = (task: AI3DTask) => {
|
||||||
if (task.resultUrl) {
|
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
|
||||||
const tenantCode = route.params.tenantCode as string
|
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({
|
router.push({
|
||||||
path: `/${tenantCode}/workbench/model-viewer`,
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
query: { url: task.resultUrl },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -612,11 +612,23 @@ const stopPolling = () => {
|
|||||||
|
|
||||||
// 预览3D模型
|
// 预览3D模型
|
||||||
const handlePreview = (task: AI3DTask) => {
|
const handlePreview = (task: AI3DTask) => {
|
||||||
if (task.resultUrl) {
|
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
|
||||||
const tenantCode = route.params.tenantCode as string
|
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({
|
router.push({
|
||||||
path: `/${tenantCode}/workbench/model-viewer`,
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
query: { url: task.resultUrl },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user