Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
3
3DMobileRender
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xhw
3DMobileRender
Commits
a9dcf2ed
Commit
a9dcf2ed
authored
Jul 29, 2026
by
xhw
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
uploadImage
parent
3a29c1d7
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
401 additions
and
126 deletions
+401
-126
SparkSplatViewer.js
+31
-6
index.html
+370
-120
No files found.
SparkSplatViewer.js
View file @
a9dcf2ed
...
@@ -43,7 +43,7 @@ export class SparkSplatViewer {
...
@@ -43,7 +43,7 @@ export class SparkSplatViewer {
// Spark渲染器配置
// Spark渲染器配置
maxStdDev
:
options
.
maxStdDev
??
Math
.
sqrt
(
5
),
maxStdDev
:
options
.
maxStdDev
??
Math
.
sqrt
(
5
),
sortRadial
:
options
.
sortRadial
??
tru
e
,
sortRadial
:
options
.
sortRadial
??
fals
e
,
enableLod
:
options
.
enableLod
??
true
,
enableLod
:
options
.
enableLod
??
true
,
lodSplatScale
:
options
.
lodSplatScale
??
1.0
,
lodSplatScale
:
options
.
lodSplatScale
??
1.0
,
...
@@ -117,11 +117,17 @@ export class SparkSplatViewer {
...
@@ -117,11 +117,17 @@ export class SparkSplatViewer {
antialias
:
false
,
antialias
:
false
,
alpha
:
false
,
alpha
:
false
,
canvas
:
options
.
canvas
||
undefined
,
canvas
:
options
.
canvas
||
undefined
,
powerPreference
:
"high-performance"
,
// 强制独显
depth
:
false
,
// 3DGS 不需要 depth
stencil
:
false
,
});
});
this
.
renderer
.
setSize
(
this
.
container
.
clientWidth
,
this
.
container
.
clientHeight
);
this
.
renderer
.
setSize
(
this
.
container
.
clientWidth
,
this
.
container
.
clientHeight
);
this
.
renderer
.
setPixelRatio
(
Math
.
min
(
window
.
devicePixelRatio
,
2
));
this
.
renderer
.
setPixelRatio
(
Math
.
min
(
window
.
devicePixelRatio
,
1
));
this
.
renderer
.
toneMapping
=
THREE
.
ACESFilmicToneMapping
;
// this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this
.
renderer
.
toneMappingExposure
=
1
;
// this.renderer.toneMappingExposure = 1;
// 改为:
this
.
renderer
.
toneMapping
=
THREE
.
NoToneMapping
;
// 或 LinearToneMapping
if
(
!
options
.
canvas
)
{
if
(
!
options
.
canvas
)
{
this
.
container
.
appendChild
(
this
.
renderer
.
domElement
);
this
.
container
.
appendChild
(
this
.
renderer
.
domElement
);
...
@@ -293,17 +299,35 @@ export class SparkSplatViewer {
...
@@ -293,17 +299,35 @@ export class SparkSplatViewer {
_startRenderLoop
()
{
_startRenderLoop
()
{
const
animate
=
()
=>
{
const
animate
=
()
=>
{
if
(
this
.
isDestroyed
)
return
;
if
(
this
.
isDestroyed
)
return
;
requestAnimationFrame
(
animate
);
// 先预约下一帧,避免掉帧
const
deltaTime
=
this
.
_clock
.
getDelta
();
const
deltaTime
=
this
.
_clock
.
getDelta
();
// 自动旋转
(仅在用户未交互时)
// 自动旋转
if
(
this
.
options
.
autoRotate
&&
!
this
.
_isUserInteracting
)
{
if
(
this
.
options
.
autoRotate
&&
!
this
.
_isUserInteracting
)
{
this
.
_doAutoRotate
(
deltaTime
);
this
.
_doAutoRotate
(
deltaTime
);
this
.
_needsRender
=
true
;
}
}
// 控制器阻尼更新
if
(
this
.
controls
.
enableDamping
)
{
const
oldPos
=
this
.
_lastCameraPos
.
clone
();
const
oldQuat
=
this
.
_lastCameraQuat
.
clone
();
this
.
controls
.
update
();
if
(
!
this
.
camera
.
position
.
equals
(
oldPos
)
||
!
this
.
camera
.
quaternion
.
equals
(
oldQuat
))
{
this
.
_needsRender
=
true
;
this
.
_lastCameraPos
.
copy
(
this
.
camera
.
position
);
this
.
_lastCameraQuat
.
copy
(
this
.
camera
.
quaternion
);
}
}
else
{
this
.
controls
.
update
();
this
.
controls
.
update
();
}
// 只在需要时渲染
if
(
this
.
_needsRender
)
{
this
.
renderer
.
render
(
this
.
scene
,
this
.
camera
);
this
.
renderer
.
render
(
this
.
scene
,
this
.
camera
);
requestAnimationFrame
(
animate
);
this
.
_needsRender
=
false
;
}
};
};
requestAnimationFrame
(
animate
);
requestAnimationFrame
(
animate
);
}
}
...
@@ -316,6 +340,7 @@ export class SparkSplatViewer {
...
@@ -316,6 +340,7 @@ export class SparkSplatViewer {
this
.
camera
.
aspect
=
w
/
h
;
this
.
camera
.
aspect
=
w
/
h
;
this
.
camera
.
updateProjectionMatrix
();
this
.
camera
.
updateProjectionMatrix
();
this
.
renderer
.
setSize
(
w
,
h
);
this
.
renderer
.
setSize
(
w
,
h
);
this
.
renderer
.
setPixelRatio
(
Math
.
min
(
window
.
devicePixelRatio
,
1.0
));
// ← 保持限制
}
}
/** 获取设备类型 */
/** 获取设备类型 */
...
...
index.html
View file @
a9dcf2ed
...
@@ -44,7 +44,7 @@
...
@@ -44,7 +44,7 @@
#upload-toast
.success
{
background
:
rgba
(
34
,
139
,
74
,
0.95
);
}
#upload-toast
.success
{
background
:
rgba
(
34
,
139
,
74
,
0.95
);
}
#upload-toast
.error
{
background
:
rgba
(
178
,
48
,
48
,
0.95
);
}
#upload-toast
.error
{
background
:
rgba
(
178
,
48
,
48
,
0.95
);
}
#upload-toast
.loading
{
background
:
rgba
(
40
,
40
,
60
,
0.92
);
}
#upload-toast
.loading
{
background
:
rgba
(
40
,
40
,
60
,
0.92
);
}
/* Ctrl+O 快捷键提示
(不突兀:角落常驻、低透明度)
*/
/* Ctrl+O 快捷键提示 */
#open-file-hint
{
#open-file-hint
{
position
:
fixed
;
position
:
fixed
;
right
:
16px
;
right
:
16px
;
...
@@ -77,17 +77,81 @@
...
@@ -77,17 +77,81 @@
font-family
:
inherit
;
font-family
:
inherit
;
font-size
:
11px
;
font-size
:
11px
;
}
}
/* FPS 计数器 */
#fps-counter
{
position
:
fixed
;
top
:
14px
;
left
:
14px
;
padding
:
5px
12px
;
border-radius
:
6px
;
background
:
rgba
(
20
,
20
,
30
,
0.75
);
color
:
#00ff88
;
font-family
:
'Courier New'
,
monospace
;
font-size
:
13px
;
font-weight
:
bold
;
z-index
:
10000
;
pointer-events
:
none
;
letter-spacing
:
0.5px
;
border
:
1px
solid
rgba
(
0
,
255
,
136
,
0.2
);
}
/* 后端生成模型按钮 */
#backend-generate-btn
{
position
:
fixed
;
top
:
14px
;
right
:
14px
;
padding
:
8px
16px
;
border-radius
:
8px
;
border
:
1px
solid
rgba
(
100
,
150
,
255
,
0.35
);
background
:
rgba
(
30
,
40
,
70
,
0.75
);
color
:
#aaccff
;
font-size
:
13px
;
font-family
:
inherit
;
cursor
:
pointer
;
z-index
:
10001
;
transition
:
all
0.2s
ease
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
backdrop-filter
:
blur
(
4px
);
}
#backend-generate-btn
:hover
{
background
:
rgba
(
40
,
60
,
110
,
0.9
);
color
:
#fff
;
border-color
:
rgba
(
100
,
150
,
255
,
0.7
);
transform
:
translateY
(
-1px
);
}
#backend-generate-btn
:active
{
transform
:
translateY
(
0
);
}
#backend-generate-btn
:disabled
{
opacity
:
0.5
;
cursor
:
not-allowed
;
transform
:
none
;
}
#backend-generate-btn
.spinner
{
display
:
inline-block
;
width
:
12px
;
height
:
12px
;
border
:
2px
solid
rgba
(
170
,
204
,
255
,
0.3
);
border-top-color
:
#aaccff
;
border-radius
:
50%
;
animation
:
spin
0.8s
linear
infinite
;
}
@keyframes
spin
{
to
{
transform
:
rotate
(
360deg
);
}
}
</style>
</style>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
<base
target=
"_blank"
>
</head>
</head>
<body>
<body>
<div
id=
"viewer-container"
></div>
<div
id=
"viewer-container"
></div>
<!-- FPS 显示 -->
<div
id=
"fps-counter"
>
FPS: 0
</div>
<!-- 后端生成模型按钮 -->
<button
id=
"backend-generate-btn"
title=
"选择图片,上传到后端生成 3D 模型"
>
🖼️ 上传图片生成模型
</button>
<!-- 隐藏的图片选择框(后端生成用) -->
<input
type=
"file"
id=
"image-file-input"
accept=
"image/jpeg,image/jpg,image/png,image/webp,image/bmp"
style=
"display:none"
/>
<!-- Ctrl+O 本地上传:隐藏文件选择框 + 结果提示 -->
<!-- Ctrl+O 本地上传:隐藏文件选择框 + 结果提示 -->
<input
type=
"file"
id=
"spz-file-input"
accept=
".spz"
style=
"display:none"
/>
<input
type=
"file"
id=
"spz-file-input"
accept=
".spz"
style=
"display:none"
/>
<div
id=
"upload-toast"
></div>
<div
id=
"upload-toast"
></div>
...
@@ -96,40 +160,116 @@
...
@@ -96,40 +160,116 @@
<script
type=
"module"
>
<script
type=
"module"
>
import
{
SparkSplatViewer
}
from
"./SparkSplatViewer.js"
;
import
{
SparkSplatViewer
}
from
"./SparkSplatViewer.js"
;
// 初始化组件
// ═══════════════════════════════════════════════════
// 后端接口配置
// ═══════════════════════════════════════════════════
const
BACKEND_CONFIG
=
{
// JWT 登录接口
loginUrl
:
'https://admin.mindepoch.com:9999/VegaLoginJWT'
,
// 生成模型接口
predictUrl
:
'https://admin.mindepoch.com:9999/GSPredict'
,
// 登录凭据
credentials
:
{
username
:
'Vega001'
,
password
:
'88888888abc'
,
},
};
// Token 缓存(内存 + localStorage 双保险)
let
cachedToken
=
null
;
function
getStoredToken
()
{
if
(
cachedToken
)
return
cachedToken
;
try
{
return
localStorage
.
getItem
(
'vega_jwt_token'
)
||
null
;
}
catch
(
e
)
{
return
null
;
}
}
function
setStoredToken
(
token
)
{
cachedToken
=
token
;
try
{
if
(
token
)
{
localStorage
.
setItem
(
'vega_jwt_token'
,
token
);
}
else
{
localStorage
.
removeItem
(
'vega_jwt_token'
);
}
}
catch
(
e
)
{
// localStorage 不可用,仅内存缓存
}
}
function
clearStoredToken
()
{
cachedToken
=
null
;
try
{
localStorage
.
removeItem
(
'vega_jwt_token'
);
}
catch
(
e
)
{}
}
// ═══════════════════════════════════════════════════
// JWT 登录:获取 Access Token
// ═══════════════════════════════════════════════════
/**
* 调用 VegaLoginJWT 接口获取 token
* @returns {Promise<string>} JWT token
*/
async
function
loginAndGetToken
()
{
showToast
(
'正在登录获取 Token…'
,
'loading'
,
0
);
console
.
log
(
'[Auth] POST'
,
BACKEND_CONFIG
.
loginUrl
);
const
response
=
await
fetch
(
BACKEND_CONFIG
.
loginUrl
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
},
body
:
JSON
.
stringify
(
BACKEND_CONFIG
.
credentials
),
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`登录失败 HTTP
${
response
.
status
}
:
${
response
.
statusText
}
`
);
}
const
data
=
await
response
.
json
();
console
.
log
(
'[Auth] 登录返回:'
,
data
);
// 兼容多种返回格式:data.token / data.data.token / data.access_token
const
token
=
data
.
token
||
data
.
access_token
||
(
data
.
data
&&
data
.
data
.
token
)
||
(
data
.
data
&&
data
.
data
.
access_token
);
if
(
!
token
)
{
throw
new
Error
(
`登录成功但未返回 token,返回内容:
${
JSON
.
stringify
(
data
)}
`
);
}
setStoredToken
(
token
);
showToast
(
'登录成功,Token 已获取'
,
'success'
,
1500
);
console
.
log
(
'[Auth] Token 已缓存'
);
return
token
;
}
// ═══════════════════════════════════════════════════
// 初始化 3D 渲染器
// ═══════════════════════════════════════════════════
const
container
=
document
.
getElementById
(
"viewer-container"
);
const
container
=
document
.
getElementById
(
"viewer-container"
);
const
viewer
=
new
SparkSplatViewer
(
container
,
{
const
viewer
=
new
SparkSplatViewer
(
container
,
{
// 场景配置
backgroundColor
:
0x1a1a2e
,
backgroundColor
:
0x1a1a2e
,
// 相机配置
fov
:
45
,
fov
:
45
,
fovMobile
:
20
,
fovMobile
:
20
,
initialPosition
:
{
x
:
0
,
y
:
0
,
z
:
5
},
initialPosition
:
{
x
:
0
,
y
:
0
,
z
:
5
},
// 控制器配置
enablePan
:
false
,
enablePan
:
false
,
minPolarAngle
:
Math
.
PI
/
2
-
Math
.
PI
/
6
,
minPolarAngle
:
Math
.
PI
/
2
-
Math
.
PI
/
6
,
maxPolarAngle
:
Math
.
PI
/
2
+
Math
.
PI
/
6
,
maxPolarAngle
:
Math
.
PI
/
2
+
Math
.
PI
/
6
,
minAzimuthAngle
:
-
Math
.
PI
/
10
,
minAzimuthAngle
:
-
Math
.
PI
/
10
,
maxAzimuthAngle
:
Math
.
PI
/
10
,
maxAzimuthAngle
:
Math
.
PI
/
10
,
autoRotate
:
true
,
// 自动旋转配置(新增)
autoRotateSpeed
:
0.03
,
autoRotate
:
true
,
// 开启自动旋转
autoRotateIdleDelay
:
2000
,
autoRotateSpeed
:
0.1
,
// 速度(推荐 0.2~0.5)
autoRotateIdleDelay
:
2000
,
// 空闲多久后恢复(ms)
// 模型配置
defaultModelUrl
:
"./Advance.spz"
,
defaultModelUrl
:
"./Advance.spz"
,
modelRotationX
:
-
Math
.
PI
,
modelRotationX
:
-
Math
.
PI
,
// 缩放配置
desktopZoom
:
2.5
,
desktopZoom
:
2.5
,
mobileZoom
:
0.55
,
mobileZoom
:
0.55
,
tabletZoom
:
1.0
,
tabletZoom
:
1.0
,
// 回调
onLoad
:
(
mesh
)
=>
{
onLoad
:
(
mesh
)
=>
{
console
.
log
(
"默认模型加载完成"
,
mesh
);
console
.
log
(
"默认模型加载完成"
,
mesh
);
},
},
...
@@ -138,29 +278,32 @@
...
@@ -138,29 +278,32 @@
},
},
});
});
// 暴露到全局,方便调试和外部调用
window
.
viewer
=
viewer
;
window
.
viewer
=
viewer
;
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
//
Ctrl+O 快捷键:本地选择 .spz 文件上传并加载
//
FPS 实时显示
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
const
fpsCounter
=
document
.
getElementById
(
'fps-counter'
);
let
frameCount
=
0
;
let
fpsLastTime
=
performance
.
now
();
(
function
fpsLoop
()
{
frameCount
++
;
const
now
=
performance
.
now
();
if
(
now
-
fpsLastTime
>=
1000
)
{
fpsCounter
.
textContent
=
`FPS:
${
frameCount
}
`
;
frameCount
=
0
;
fpsLastTime
=
now
;
}
requestAnimationFrame
(
fpsLoop
);
})();
const
spzFileInput
=
document
.
getElementById
(
"spz-file-input"
);
// ═══════════════════════════════════════════════════
const
openFileHint
=
document
.
getElementById
(
"open-file-hint"
);
// Toast 提示系统
// ═══════════════════════════════════════════════════
// 页面加载完成后淡入快捷键提示
requestAnimationFrame
(()
=>
openFileHint
.
classList
.
add
(
"visible"
));
// 点击提示也可以打开文件选择框
openFileHint
.
addEventListener
(
"click"
,
()
=>
spzFileInput
.
click
());
const
uploadToast
=
document
.
getElementById
(
"upload-toast"
);
const
uploadToast
=
document
.
getElementById
(
"upload-toast"
);
let
toastTimer
=
null
;
let
toastTimer
=
null
;
/**
* 显示上传结果提示
* @param {string} msg - 提示内容
* @param {"success"|"error"|"loading"} type - 类型
* @param {number} duration - 自动隐藏时长(ms),0 表示不自动隐藏
*/
function
showToast
(
msg
,
type
=
"loading"
,
duration
=
2500
)
{
function
showToast
(
msg
,
type
=
"loading"
,
duration
=
2500
)
{
uploadToast
.
textContent
=
msg
;
uploadToast
.
textContent
=
msg
;
uploadToast
.
className
=
"show "
+
type
;
uploadToast
.
className
=
"show "
+
type
;
...
@@ -172,7 +315,185 @@
...
@@ -172,7 +315,185 @@
}
}
}
}
// Ctrl+O 打开本地文件选择框(阻止浏览器默认的"打开文件"行为)
// ═══════════════════════════════════════════════════
// 后端接口:上传图片 → 生成模型 → 自动加载(含自动登录)
// ═══════════════════════════════════════════════════
const
backendBtn
=
document
.
getElementById
(
"backend-generate-btn"
);
const
imageFileInput
=
document
.
getElementById
(
"image-file-input"
);
let
isBackendLoading
=
false
;
/**
* 调用 GSPredict 接口上传图片生成模型
* 自动处理:无 token → 先登录 → 再请求 → token 过期 → 重新登录重试
*
* @param {File} imageFile - 用户选择的图片文件
* @param {boolean} [retryOnAuthFail=true] - token 失效时是否自动重试一次
*/
async
function
generateModelFromImage
(
imageFile
,
retryOnAuthFail
=
true
)
{
if
(
isBackendLoading
)
return
;
isBackendLoading
=
true
;
backendBtn
.
disabled
=
true
;
backendBtn
.
innerHTML
=
'<span class="spinner"></span> 生成中…'
;
let
token
=
getStoredToken
();
// 如果没有 token,先登录
if
(
!
token
)
{
try
{
token
=
await
loginAndGetToken
();
}
catch
(
err
)
{
showToast
(
`登录失败:
${
err
.
message
}
`
,
'error'
);
isBackendLoading
=
false
;
backendBtn
.
disabled
=
false
;
backendBtn
.
innerHTML
=
'🖼️ 上传图片生成模型'
;
return
;
}
}
const
url
=
BACKEND_CONFIG
.
predictUrl
;
const
formData
=
new
FormData
();
formData
.
append
(
'image'
,
imageFile
);
showToast
(
`正在上传图片:
${
imageFile
.
name
}
…`
,
'loading'
,
0
);
console
.
log
(
'[Backend] POST'
,
url
,
'file:'
,
imageFile
.
name
,
'size:'
,
imageFile
.
size
);
try
{
const
response
=
await
fetch
(
url
,
{
method
:
'POST'
,
headers
:
{
'Authorization'
:
`Bearer
${
token
}
`
,
},
body
:
formData
,
});
// 处理 HTTP 401/403 或 code 408(缺少 token)→ 清除缓存并重试
if
(
response
.
status
===
401
||
response
.
status
===
403
)
{
throw
new
Error
(
'AUTH_FAIL'
);
}
if
(
!
response
.
ok
)
{
throw
new
Error
(
`HTTP
${
response
.
status
}
:
${
response
.
statusText
}
`
);
}
const
data
=
await
response
.
json
();
console
.
log
(
'[Backend] 接口返回:'
,
data
);
// 业务层鉴权失败(code 408: 缺少访问token)
if
(
data
.
code
===
408
)
{
throw
new
Error
(
'AUTH_FAIL'
);
}
// 校验返回状态码(兼容 code: 0 和 code: 200)
const
code
=
data
.
code
;
if
(
code
!==
200
&&
code
!==
0
)
{
throw
new
Error
(
data
.
message
||
`后端返回错误码:
${
code
}
`
);
}
// 提取下载链接
let
downloadUrl
=
data
.
download_url
||
data
.
file_url
;
if
(
!
downloadUrl
)
{
throw
new
Error
(
'后端未返回 download_url 或 file_url'
);
}
// 后端返回的是相对路径(如 /GSFiles/xxx.spz?...),拼接完整域名
if
(
downloadUrl
.
startsWith
(
'/'
))
{
downloadUrl
=
'https://admin.mindepoch.com:9999'
+
downloadUrl
;
}
const
filename
=
data
.
filename
||
data
.
stored_filename
||
'model.spz'
;
const
width
=
data
.
width
;
const
height
=
data
.
height
;
const
inferenceSec
=
data
.
inference_seconds
;
showToast
(
`生成成功:
${
filename
}${
width
?
` (
${
width
}
x
${
height
}
)`
:
''
}
`
+
`
${
inferenceSec
?
` 推理
${
inferenceSec
.
toFixed
(
1
)}
s`
:
''
}
,开始加载…`
,
'success'
,
3000
);
// 加载模型
window
.
loadNewModel
(
downloadUrl
,
{
autoFit
:
true
,
onBeforeLoad
:
()
=>
{
showToast
(
`正在下载模型:
${
filename
}
…`
,
'loading'
,
0
);
},
onComplete
:
(
mesh
)
=>
{
showToast
(
`模型
${
filename
}
加载完成`
,
'success'
);
console
.
log
(
'[Backend] 模型加载完成:'
,
filename
,
mesh
);
},
onError
:
(
err
)
=>
{
showToast
(
`模型加载失败:
${
err
.
message
||
err
}
`
,
'error'
);
console
.
error
(
'[Backend] 模型加载失败:'
,
err
);
},
onProgress
:
(
e
)
=>
{
if
(
e
&&
e
.
lengthComputable
)
{
const
pct
=
((
e
.
loaded
/
e
.
total
)
*
100
).
toFixed
(
0
);
showToast
(
`下载模型:
${
filename
}
…
${
pct
}
%`
,
'loading'
,
0
);
}
},
});
}
catch
(
err
)
{
// token 失效,自动重新登录并重试一次
if
(
err
.
message
===
'AUTH_FAIL'
&&
retryOnAuthFail
)
{
console
.
warn
(
'[Auth] Token 失效,尝试重新登录…'
);
clearStoredToken
();
isBackendLoading
=
false
;
backendBtn
.
disabled
=
false
;
backendBtn
.
innerHTML
=
'🖼️ 上传图片生成模型'
;
// 递归调用,但 retryOnAuthFail = false 防止无限循环
return
generateModelFromImage
(
imageFile
,
false
);
}
console
.
error
(
'[Backend] 请求失败:'
,
err
);
let
msg
=
err
.
message
||
'未知错误'
;
if
(
msg
.
includes
(
'Failed to fetch'
)
||
msg
.
includes
(
'NetworkError'
))
{
msg
+=
'(可能是跨域/CORS问题)'
;
}
showToast
(
`后端请求失败:
${
msg
}
`
,
'error'
);
}
finally
{
isBackendLoading
=
false
;
backendBtn
.
disabled
=
false
;
backendBtn
.
innerHTML
=
'🖼️ 上传图片生成模型'
;
}
}
// 暴露到全局
window
.
generateModelFromImage
=
generateModelFromImage
;
window
.
loginAndGetToken
=
loginAndGetToken
;
window
.
clearStoredToken
=
clearStoredToken
;
// 按钮点击 → 打开图片选择框
backendBtn
.
addEventListener
(
'click'
,
()
=>
{
imageFileInput
.
click
();
});
// 选中图片后自动上传并生成
imageFileInput
.
addEventListener
(
'change'
,
async
()
=>
{
const
file
=
imageFileInput
.
files
&&
imageFileInput
.
files
[
0
];
imageFileInput
.
value
=
''
;
// 允许重复选择同一文件
if
(
!
file
)
return
;
const
validTypes
=
[
'image/jpeg'
,
'image/jpg'
,
'image/png'
,
'image/webp'
,
'image/bmp'
];
if
(
!
validTypes
.
includes
(
file
.
type
))
{
showToast
(
`不支持的图片格式:
${
file
.
type
}
,请上传 jpg/png/webp/bmp`
,
'error'
);
return
;
}
await
generateModelFromImage
(
file
);
});
// ═══════════════════════════════════════════════════
// Ctrl+O 快捷键:本地选择 .spz 文件上传并加载
// ═══════════════════════════════════════════════════
const
spzFileInput
=
document
.
getElementById
(
"spz-file-input"
);
const
openFileHint
=
document
.
getElementById
(
"open-file-hint"
);
requestAnimationFrame
(()
=>
openFileHint
.
classList
.
add
(
"visible"
));
openFileHint
.
addEventListener
(
"click"
,
()
=>
spzFileInput
.
click
());
window
.
addEventListener
(
"keydown"
,
(
e
)
=>
{
window
.
addEventListener
(
"keydown"
,
(
e
)
=>
{
if
((
e
.
ctrlKey
||
e
.
metaKey
)
&&
!
e
.
shiftKey
&&
!
e
.
altKey
&&
e
.
code
===
"KeyO"
)
{
if
((
e
.
ctrlKey
||
e
.
metaKey
)
&&
!
e
.
shiftKey
&&
!
e
.
altKey
&&
e
.
code
===
"KeyO"
)
{
e
.
preventDefault
();
e
.
preventDefault
();
...
@@ -180,10 +501,9 @@
...
@@ -180,10 +501,9 @@
}
}
});
});
// 选中文件后:校验后缀 → 加载 → 提示结果
spzFileInput
.
addEventListener
(
"change"
,
async
()
=>
{
spzFileInput
.
addEventListener
(
"change"
,
async
()
=>
{
const
file
=
spzFileInput
.
files
&&
spzFileInput
.
files
[
0
];
const
file
=
spzFileInput
.
files
&&
spzFileInput
.
files
[
0
];
spzFileInput
.
value
=
""
;
// 允许重复选择同一个文件
spzFileInput
.
value
=
""
;
if
(
!
file
)
return
;
if
(
!
file
)
return
;
if
(
!
/
\.
spz$/i
.
test
(
file
.
name
))
{
if
(
!
/
\.
spz$/i
.
test
(
file
.
name
))
{
...
@@ -219,128 +539,59 @@
...
@@ -219,128 +539,59 @@
// 加载新模型的方法(供外部调用)
// 加载新模型的方法(供外部调用)
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
/**
* 加载新模型并替换当前模型
* @param {string} url - 模型文件路径 (.spz 支持 gzip v1~v3 和 NGSP/ZSTD v4;另支持 .ply, .splat, .ksplat, .sog, .rad 等)
* @param {Object} options - 可选配置
*/
window
.
loadNewModel
=
(
url
,
options
=
{})
=>
{
window
.
loadNewModel
=
(
url
,
options
=
{})
=>
{
viewer
.
loadModel
(
url
,
{
viewer
.
loadModel
(
url
,
{
autoFit
:
options
.
autoFit
!==
false
,
// 默认自动适配相机
autoFit
:
options
.
autoFit
!==
false
,
zoomDuration
:
options
.
zoomDuration
??
500
,
// 缩放动画时长
zoomDuration
:
options
.
zoomDuration
??
500
,
desktopZoom
:
options
.
desktopZoom
??
2.2
,
// 桌面端缩放
desktopZoom
:
options
.
desktopZoom
??
2.2
,
mobileZoom
:
options
.
mobileZoom
??
0.55
,
// 移动端缩放
mobileZoom
:
options
.
mobileZoom
??
0.55
,
tabletZoom
:
options
.
tabletZoom
??
1.0
,
// 平板端缩放
tabletZoom
:
options
.
tabletZoom
??
1.0
,
onBeforeLoad
:
options
.
onBeforeLoad
,
// 加载开始前回调(可显示 loading 避免白屏)
onBeforeLoad
:
options
.
onBeforeLoad
,
onComplete
:
options
.
onComplete
,
// 加载完成回调
onComplete
:
options
.
onComplete
,
onError
:
options
.
onError
,
// 加载失败回调
onError
:
options
.
onError
,
onProgress
:
options
.
onProgress
,
// 进度回调
onProgress
:
options
.
onProgress
,
});
});
};
};
// 使用示例:
// loadNewModel("./new-model.spz");
// loadNewModel("./new-model.spz", { autoFit: true, desktopZoom: 3.0 });
//
// 避免白屏:在 onBeforeLoad 中显示 loading 遮罩
// loadNewModel("./new-model.spz", {
// onBeforeLoad: () => { document.getElementById('loading').style.display = 'flex'; },
// onComplete: () => { document.getElementById('loading').style.display = 'none'; },
// onError: () => { document.getElementById('loading').style.display = 'none'; },
// });
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// 自动旋转控制方法(供外部调用)
// 自动旋转控制方法(供外部调用)
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
/**
* 开启/关闭自动旋转
* @param {boolean} enabled - true 开启, false 关闭
*/
window
.
setAutoRotate
=
(
enabled
)
=>
{
window
.
setAutoRotate
=
(
enabled
)
=>
{
viewer
.
setAutoRotate
(
enabled
);
viewer
.
setAutoRotate
(
enabled
);
};
};
/**
* 获取当前自动旋转状态
* @returns {boolean}
*/
window
.
getAutoRotate
=
()
=>
{
window
.
getAutoRotate
=
()
=>
{
return
viewer
.
getAutoRotate
();
return
viewer
.
getAutoRotate
();
};
};
/**
* 设置自动旋转速度
* @param {number} speed - 速度值(推荐 0.1 ~ 1.0)
*/
window
.
setAutoRotateSpeed
=
(
speed
)
=>
{
window
.
setAutoRotateSpeed
=
(
speed
)
=>
{
viewer
.
setAutoRotateSpeed
(
speed
);
viewer
.
setAutoRotateSpeed
(
speed
);
};
};
/**
* 获取当前自动旋转速度
* @returns {number}
*/
window
.
getAutoRotateSpeed
=
()
=>
{
window
.
getAutoRotateSpeed
=
()
=>
{
return
viewer
.
getAutoRotateSpeed
();
return
viewer
.
getAutoRotateSpeed
();
};
};
/**
* 设置空闲延迟时间
* @param {number} delayMs - 延迟时间(毫秒)
*/
window
.
setAutoRotateIdleDelay
=
(
delayMs
)
=>
{
window
.
setAutoRotateIdleDelay
=
(
delayMs
)
=>
{
viewer
.
setAutoRotateIdleDelay
(
delayMs
);
viewer
.
setAutoRotateIdleDelay
(
delayMs
);
};
};
/**
* 获取当前空闲延迟时间
* @returns {number}
*/
window
.
getAutoRotateIdleDelay
=
()
=>
{
window
.
getAutoRotateIdleDelay
=
()
=>
{
return
viewer
.
getAutoRotateIdleDelay
();
return
viewer
.
getAutoRotateIdleDelay
();
};
};
/**
* 立即暂停自动旋转
*/
window
.
pauseAutoRotate
=
()
=>
{
window
.
pauseAutoRotate
=
()
=>
{
viewer
.
pauseAutoRotate
();
viewer
.
pauseAutoRotate
();
};
};
/**
* 恢复自动旋转
*/
window
.
resumeAutoRotate
=
()
=>
{
window
.
resumeAutoRotate
=
()
=>
{
viewer
.
resumeAutoRotate
();
viewer
.
resumeAutoRotate
();
};
};
/**
* 切换自动旋转状态
* @returns {boolean} 切换后的状态
*/
window
.
toggleAutoRotate
=
()
=>
{
window
.
toggleAutoRotate
=
()
=>
{
return
viewer
.
toggleAutoRotate
();
return
viewer
.
toggleAutoRotate
();
};
};
// 其他可用方法:
// viewer.resetCamera(); // 重置相机
// viewer.fitCameraToModel(); // 适配相机到模型
// viewer.getCameraInfo(); // 获取相机信息
// viewer.animateZoomTo(2.0, 800); // 平滑缩放到指定值
// viewer.destroy(); // 销毁组件
//
// 自动旋转控制:
// setAutoRotate(true/false); // 开启/关闭自动旋转
// toggleAutoRotate(); // 切换自动旋转状态
// setAutoRotateSpeed(0.5); // 设置旋转速度
// setAutoRotateIdleDelay(3000); // 设置空闲延迟(ms)
// pauseAutoRotate(); // 暂停自动旋转
// resumeAutoRotate(); // 恢复自动旋转
// 获取模型包围盒
// const bounds = viewer.getModelBounds();
</script>
</script>
</body>
</body>
</html>
</html>
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment