Commit 5967c3da by xhw

upload

parent cc7b6acb
......@@ -448,6 +448,75 @@ export class SparkSplatViewer {
}
/**
* 从本地 File 对象加载模型(用于 Ctrl+O 文件上传等场景)
*
* 与 loadModel(url) 的区别:
* 直接读取 File 字节,不经过 fetch / blob URL,
* 因此 .spz 的 v4→v3 格式适配一定生效(blob URL 无法通过扩展名正则判断)。
* 读取与格式转换成功后才会移除旧模型——上传失败时旧模型保留。
*
* @param {File|Blob} file - 本地文件对象(建议带 name,用于判断 .spz)
* @param {Object} loadOptions - 同 loadModel
* @returns {Promise<SplatMesh|null>} 成功返回新模型,失败返回 null(并触发 onError)
*/
async loadModelFromFile(file, loadOptions = {}) {
const {
autoFit = true,
zoomDuration = this.options.zoomDuration,
desktopZoom = this.options.desktopZoom,
mobileZoom = this.options.mobileZoom,
tabletZoom = this.options.tabletZoom,
onBeforeLoad = null,
onComplete = null,
onError = null,
onProgress = null,
} = loadOptions;
// 加载开始前回调(可显示 loading 遮罩)
if (onBeforeLoad) onBeforeLoad();
if (this.options.onBeforeLoad) this.options.onBeforeLoad();
const fileName = (file && file.name) || "(未命名)";
try {
if (!file) throw new Error("未选择文件");
if (file.size === 0) throw new Error("文件为空");
// 1. 读取本地文件字节
let fileBytes = new Uint8Array(await file.arrayBuffer());
// 2. .spz 文件做格式适配:NGSP v4 (zstd) → gzip v3;gzip v1~v3 原样直通
const isSpz = /\.spz$/i.test(fileName);
if (isSpz) {
fileBytes = await ensureSparkCompatibleSpz(fileBytes);
}
// 3. 字节就绪后再移除旧模型(失败时旧模型保留,不白屏)
this._disposeCurrentModel();
// 4. 创建新模型;SplatMesh 的 onLoad/onError 是回调式的,这里包成 Promise
return await new Promise((resolve) => {
this._createSplatMesh({ fileBytes }, {
autoFit, zoomDuration, desktopZoom, mobileZoom, tabletZoom,
onComplete: (mesh) => {
if (onComplete) onComplete(mesh);
resolve(mesh);
},
onError: (err) => {
if (onError) onError(err);
resolve(null);
},
onProgress,
});
});
} catch (err) {
console.error(`[SparkSplatViewer] 本地文件加载失败: ${fileName}`, err);
if (onError) onError(err);
if (this.options.onError) this.options.onError(err);
return null;
}
}
/**
* 下载 .spz → 检测格式 → v4 转 v3 → fileBytes 喂给 SplatMesh
* @private
*/
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D高斯泼溅 - SparkSplatViewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
overflow: hidden;
background: #1a1a2e;
font-family: 'Segoe UI', Arial, sans-serif;
width: 100vw;
height: 100vh;
}
#viewer-container {
width: 100%;
height: 100%;
}
</style>
<base target="_blank">
<base target="_blank">
</head>
<body>
<div id="viewer-container"></div>
<script type="module">
import { SparkSplatViewer } from "./SparkSplatViewer.js";
// 初始化组件
const container = document.getElementById("viewer-container");
const viewer = new SparkSplatViewer(container, {
// 场景配置
backgroundColor: 0x1a1a2e,
// 相机配置
fov: 45,
fovMobile: 20,
initialPosition: { x: 0, y: 0, z: 5 },
// 控制器配置
enablePan: false,
minPolarAngle: Math.PI / 2 - Math.PI / 6,
maxPolarAngle: Math.PI / 2 + Math.PI / 6,
minAzimuthAngle: -Math.PI / 10,
maxAzimuthAngle: Math.PI / 10,
// 自动旋转配置(新增)
autoRotate: true, // 开启自动旋转
autoRotateSpeed: 0.1, // 速度(推荐 0.2~0.5)
autoRotateIdleDelay: 2000, // 空闲多久后恢复(ms)
// 模型配置
defaultModelUrl: "./Advance.spz",
modelRotationX: -Math.PI,
// 缩放配置
desktopZoom: 2.5,
mobileZoom: 0.55,
tabletZoom: 1.0,
// 回调
onLoad: (mesh) => {
console.log("默认模型加载完成", mesh);
},
onError: (err) => {
console.error("模型加载失败", err);
},
});
// 暴露到全局,方便调试和外部调用
window.viewer = viewer;
// ═══════════════════════════════════════════════════
// 加载新模型的方法(供外部调用)
// ═══════════════════════════════════════════════════
/**
* 加载新模型并替换当前模型
* @param {string} url - 模型文件路径 (.spz, .ply, .splat, .ksplat, .sog, .rad 等)
* @param {Object} options - 可选配置
*/
window.loadNewModel = (url, options = {}) => {
viewer.loadModel(url, {
autoFit: options.autoFit !== false, // 默认自动适配相机
zoomDuration: options.zoomDuration ?? 500, // 缩放动画时长
desktopZoom: options.desktopZoom ?? 2.2, // 桌面端缩放
mobileZoom: options.mobileZoom ?? 0.55, // 移动端缩放
tabletZoom: options.tabletZoom ?? 1.0, // 平板端缩放
onComplete: options.onComplete, // 加载完成回调
onError: options.onError, // 加载失败回调
onProgress: options.onProgress, // 进度回调
});
};
// 使用示例:
// loadNewModel("./new-model.spz");
// loadNewModel("./new-model.spz", { autoFit: true, desktopZoom: 3.0 });
// 其他可用方法:
// viewer.resetCamera(); // 重置相机
// viewer.fitCameraToModel(); // 适配相机到模型
// viewer.getCameraInfo(); // 获取相机信息
// viewer.animateZoomTo(2.0, 800); // 平滑缩放到指定值
// viewer.destroy(); // 销毁组件
// 获取模型包围盒
// const bounds = viewer.getModelBounds();
</script>
</body>
</html>
\ No newline at end of file
......@@ -17,6 +17,66 @@
width: 100%;
height: 100%;
}
/* 上传结果提示 Toast */
#upload-toast {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%) translateY(-12px);
padding: 10px 22px;
border-radius: 8px;
color: #fff;
font-size: 14px;
line-height: 1.5;
background: rgba(40, 40, 60, 0.92);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 9999;
max-width: 80vw;
word-break: break-all;
}
#upload-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#upload-toast.success { background: rgba(34, 139, 74, 0.95); }
#upload-toast.error { background: rgba(178, 48, 48, 0.95); }
#upload-toast.loading { background: rgba(40, 40, 60, 0.92); }
/* Ctrl+O 快捷键提示(不突兀:角落常驻、低透明度) */
#open-file-hint {
position: fixed;
right: 16px;
bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
background: rgba(30, 30, 46, 0.55);
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
line-height: 1;
user-select: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.6s ease, background 0.2s ease, color 0.2s ease;
z-index: 9998;
}
#open-file-hint.visible { opacity: 1; }
#open-file-hint:hover {
background: rgba(30, 30, 46, 0.85);
color: rgba(255, 255, 255, 0.95);
}
#open-file-hint kbd {
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.08);
font-family: inherit;
font-size: 11px;
}
</style>
<base target="_blank">
<base target="_blank">
......@@ -24,9 +84,14 @@
<base target="_blank">
<base target="_blank">
<base target="_blank">
<base target="_blank">
</head>
<body>
<div id="viewer-container"></div>
<!-- Ctrl+O 本地上传:隐藏文件选择框 + 结果提示 -->
<input type="file" id="spz-file-input" accept=".spz" style="display:none" />
<div id="upload-toast"></div>
<div id="open-file-hint" title="打开本地 .spz 模型文件"><kbd>Ctrl</kbd>+<kbd>O</kbd><span>打开本地模型</span></div>
<script type="module">
import { SparkSplatViewer } from "./SparkSplatViewer.js";
......@@ -77,6 +142,80 @@
window.viewer = viewer;
// ═══════════════════════════════════════════════════
// 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());
const uploadToast = document.getElementById("upload-toast");
let toastTimer = null;
/**
* 显示上传结果提示
* @param {string} msg - 提示内容
* @param {"success"|"error"|"loading"} type - 类型
* @param {number} duration - 自动隐藏时长(ms),0 表示不自动隐藏
*/
function showToast(msg, type = "loading", duration = 2500) {
uploadToast.textContent = msg;
uploadToast.className = "show " + type;
if (toastTimer) clearTimeout(toastTimer);
if (duration > 0) {
toastTimer = setTimeout(() => {
uploadToast.className = "";
}, duration);
}
}
// Ctrl+O 打开本地文件选择框(阻止浏览器默认的"打开文件"行为)
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.code === "KeyO") {
e.preventDefault();
spzFileInput.click();
}
});
// 选中文件后:校验后缀 → 加载 → 提示结果
spzFileInput.addEventListener("change", async () => {
const file = spzFileInput.files && spzFileInput.files[0];
spzFileInput.value = ""; // 允许重复选择同一个文件
if (!file) return;
if (!/\.spz$/i.test(file.name)) {
showToast("上传失败:仅支持 .spz 文件", "error");
return;
}
showToast(`正在加载:${file.name} …`, "loading", 0);
try {
const mesh = await viewer.loadModelFromFile(file, {
autoFit: true,
onProgress: (e) => {
if (e && e.lengthComputable) {
const pct = ((e.loaded / e.total) * 100).toFixed(0);
showToast(`正在加载:${file.name}${pct}%`, "loading", 0);
}
},
});
if (mesh) {
showToast(`上传成功:${file.name}`, "success");
console.log("[Ctrl+O] 上传成功", file.name, mesh);
} else {
showToast(`上传失败:${file.name},模型解析出错`, "error");
}
} catch (err) {
console.error("[Ctrl+O] 上传失败", err);
showToast(`上传失败:${err && err.message ? err.message : "未知错误"}`, "error");
}
});
// ═══════════════════════════════════════════════════
// 加载新模型的方法(供外部调用)
// ═══════════════════════════════════════════════════
......
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>body { margin: 0; overflow: hidden; }</style>
<script type="importmap">
{ "imports": { "playcanvas": "https://cdn.jsdelivr.net/npm/playcanvas/+esm" } }
</script>
</head>
<body>
<script type="module">
import * as pc from 'playcanvas';
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const app = new pc.Application(canvas, {
graphicsDeviceOptions: { antialias: false }
});
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
window.addEventListener('resize', () => app.resizeCanvas());
// 相机(这个场景的包围盒约 ±200 单位,像厘米尺度,所以放远一点)
const camera = new pc.Entity('camera');
camera.addComponent('camera', { nearClip: 1, farClip: 2000 });
camera.setPosition(150, 120, 250);
camera.lookAt(0, 0, 0);
app.root.addChild(camera);
// 关键:asset 直接指向 lod-meta.json,gsplat 组件开 unified
const asset = new pc.Asset('scene', 'gsplat', {
url: './les-tanins/lod-meta.json'
});
app.assets.add(asset);
app.assets.load(asset);
asset.ready(() => {
const splat = new pc.Entity('splat');
splat.addComponent('gsplat', { asset, unified: true }); // unified = 流式 LOD 渲染
app.root.addChild(splat);
});
</script>
</body>
</html>
\ No newline at end of file
# Ctrl+O 本地 .spz 文件上传功能说明
# Ctrl+O 本地 .spz 文件上传功能说明
## 功能概述
在 3D 高斯泼溅查看器(SparkSplatViewer)页面中,按下 **Ctrl+O**(macOS 为 ⌘+O)即可打开本地文件选择框,选择一个 `.spz` 模型文件并立即加载替换当前模型。
- **上传成功**:顶部显示绿色提示「上传成功:文件名」,新模型自动适配相机视角并恢复自动旋转。
- **上传失败**:顶部显示红色提示「上传失败:原因」,旧模型保留,不会白屏。
## 使用方式
| 操作 | 效果 |
| --- | --- |
| `Ctrl + O` / `⌘ + O` | 弹出本地文件选择框(仅允许 `.spz`) |
| 点击右下角提示条 | 等同于按快捷键,同样弹出文件选择框 |
| 选择 `.spz` 文件 | 显示加载进度,完成后替换当前模型 |
| 选择非 `.spz` 文件 | 直接提示「上传失败:仅支持 .spz 文件」 |
页面右下角常驻一个不突兀的快捷键提示条(半透明小字 `Ctrl + O 打开本地模型`),鼠标悬停时变清晰,点击也可触发上传。
## 支持的文件格式
| 格式 | 说明 |
| --- | --- |
| SPZ v1 ~ v3(gzip 封装) | 直接解码加载 |
| SPZ v4(NGSP / ZSTD) | 前端自动转码为 v3 后加载,无需后端改动 |
> 选择文件时仅校验 `.spz` 后缀;gzip 与 NGSP v4 由文件头魔数自动识别,两种格式均可直接上传。
## 交互反馈(Toast)
页面顶部居中显示状态提示,2.5 秒后自动消失:
- **加载中**`正在加载:xxx.spz … 45%`(含进度百分比)
- **成功**:绿色 `上传成功:xxx.spz`
- **失败**:红色 `上传失败:具体原因`(如文件为空、解析出错、HTTP 错误等)
## 技术实现
### 1. 新增 API:`viewer.loadModelFromFile(file, options)`
位于 `SparkSplatViewer.js`,供任何需要"从本地 File 对象加载模型"的场景调用:
```js
const mesh = await viewer.loadModelFromFile(file, {
autoFit: true, // 加载后自动适配相机(默认 true)
zoomDuration: 500, // 缩放动画时长 ms
onBeforeLoad: () => {}, // 读取文件前触发(可显示 loading 遮罩)
onComplete: (mesh) => {}, // 加载成功回调
onError: (err) => {}, // 加载失败回调
onProgress: (e) => {}, // 进度回调
});
// 成功返回 SplatMesh,失败返回 null
```
设计要点:
1. **直接读取 File 字节**`file.arrayBuffer()`),不经过 fetch / blob URL。原 `loadModel(url)` 靠 URL 后缀判断是否走 v4→v3 转码,blob URL 没有 `.spz` 后缀会绕过适配器导致 v4 文件报 "Invalid gzip header";新路径按文件名判断,转码必定生效。
2. **先读取并转码,成功后才移除旧模型**。读取或转码失败时旧模型保留在场景中,避免白屏。
3. 返回 Promise,内部把 SplatMesh 的回调式 `onLoad`/`onError` 包装为异步返回。
### 2. 快捷键与提示(index.html)
- 监听 `keydown``Ctrl+O` / `⌘+O`(不带 Shift/Alt)时 `preventDefault()` 拦截浏览器默认行为,触发隐藏的 `<input type="file" accept=".spz">`
- 每次选择后清空 input 的 value,保证重复选择同一文件也能触发 `change`
- 右下角 `#open-file-hint` 提示条:加载完成后淡入,低透明度常驻,悬停高亮,点击等效于快捷键。
- `#upload-toast` 顶部提示条:`showToast(msg, type, duration)` 统一管理,支持 `loading`(不自动消失)、`success``error` 三种状态。
## 文件变更清单
| 文件 | 变更 |
| --- | --- |
| `SparkSplatViewer.js` | 新增 `loadModelFromFile(file, options)` 方法 |
| `index.html` | 新增 Toast 样式与节点、快捷键提示条、隐藏文件输入框、Ctrl+O 监听与上传逻辑 |
其余文件(`spz-v4-adapter.js``js/` 目录下的引擎文件等)无需改动,直接替换上述两个文件即可使用。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment