Commit 5967c3da by xhw

upload

parent cc7b6acb
...@@ -448,6 +448,75 @@ export class SparkSplatViewer { ...@@ -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 * 下载 .spz → 检测格式 → v4 转 v3 → fileBytes 喂给 SplatMesh
* @private * @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 @@ ...@@ -17,6 +17,66 @@
width: 100%; width: 100%;
height: 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> </style>
<base target="_blank"> <base target="_blank">
<base target="_blank"> <base target="_blank">
...@@ -24,9 +84,14 @@ ...@@ -24,9 +84,14 @@
<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>
<!-- 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"> <script type="module">
import { SparkSplatViewer } from "./SparkSplatViewer.js"; import { SparkSplatViewer } from "./SparkSplatViewer.js";
...@@ -77,6 +142,80 @@ ...@@ -77,6 +142,80 @@
window.viewer = viewer; 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 lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D高斯泼溅 - 视角探索</title>
<style>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', Arial, sans-serif; }
canvas { display: block; }
</style>
<base target="_blank">
</head>
<body>
<script type="module">
import * as THREE from "./js/three.module.js";
import { SparkRenderer, SplatMesh } from "./js/spark.module.js";
import { OrbitControls } from "./js/OrbitControls.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 场景 / 相机 / 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
let zoomAnimId = null;
// let autoCropEnabled = false;
const aspect = window.innerWidth / window.innerHeight;
// ── 透视相机 ──
const state = getDeviceType();
let perspCamera;
if(state === "desktop") {
perspCamera = new THREE.PerspectiveCamera(45, aspect, 0.001, 10000);
}else{
perspCamera = new THREE.PerspectiveCamera(20, aspect, 0.001, 10000);
}
// const perspCamera = new THREE.PerspectiveCamera(20, aspect, 0.001, 10000);
// ★ 修复:初始位置设为 Z 轴方向,与模型坐标系一致
perspCamera.position.set(0, 0, 5);
let camera = perspCamera;
const renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false // ★ 修复3:关闭 alpha,避免背景混合产生边缘杂色
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
document.body.appendChild(renderer.domElement);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 轨道控制器 —— 核心修复区域
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxDistance = 100;
controls.minDistance = 0.01;
// 限制转动范围:上下左右各10度
controls.minPolarAngle = Math.PI / 2 - Math.PI / 6;
controls.maxPolarAngle = Math.PI / 2 + Math.PI / 6;
controls.minAzimuthAngle = -Math.PI / 10;
controls.maxAzimuthAngle = Math.PI / 10;
controls.target.set(0, 0, 0);
// ★ 修复:设置正确的"上方向",确保旋转行为符合直觉
controls.object.up.set(0, 0, 0); // Y 轴向上(标准设置)
// 取消鼠标右键拖动(平移)
controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: null
};
controls.update();
// 保存初始视角
const HOME_POS = perspCamera.position.clone();
const HOME_TARGET = controls.target.clone();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. Spark 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const spark = new SparkRenderer({
renderer,
maxStdDev: Math.sqrt(5), // 限制高斯核范围,提升性能
sortRadial: true, // 径向排序,快速旋转时避免黑边(默认就是 true)
enableLod: true, // 启用 LoD(大场景必需)
lodSplatScale: 1.0 // 细节倍率
});
scene.add(spark);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 5. 模型管理(支持加载新模型替换旧模型)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let splatMesh = null;
function initDefaultModel() {
splatMesh = new SplatMesh({
url: './A11.spz',
onLoad: (mesh) => {
console.log("模型加载完成");
mesh.rotation.x = -Math.PI;
fitCameraToModel();
state === "desktop" ? animateZoomTo(2.5, 500) : animateZoomTo(0.55, 500);
controls.update();
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`加载进度: ${pct}%`);
}
}
});
scene.add(splatMesh);
window.__splat = splatMesh;
}
// 初始化默认模型
initDefaultModel();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 6. 加载新模型(替换旧模型)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* 加载新模型并替换当前模型
* @param {string} url - 新模型文件路径
* @param {Object} options - 配置选项
* @param {boolean} options.autoFit - 是否自动调整相机视角,默认 true
* @param {number} options.zoomDuration - 缩放动画时长(ms),默认 500
* @param {number} options.desktopZoom - 桌面端目标 zoom,默认 2.2
* @param {number} options.mobileZoom - 移动端目标 zoom,默认 0.55
* @param {Function} options.onComplete - 加载完成回调
* @param {Function} options.onError - 加载失败回调
* @param {Function} options.onProgress - 加载进度回调
* @returns {SplatMesh} 新创建的 SplatMesh 实例
*/
function loadNewModel(url, options = {}) {
const {
autoFit = true,
zoomDuration = 500,
desktopZoom = 2.2,
mobileZoom = 0.55,
onComplete = null,
onError = null,
onProgress = null
} = options;
// 1. 移除旧模型
if (splatMesh) {
// 停止进行中的 zoom 动画
if (zoomAnimId) {
cancelAnimationFrame(zoomAnimId);
zoomAnimId = null;
}
// 从场景中移除
scene.remove(splatMesh);
// 清理资源(防止内存泄漏)
splatMesh.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => {
if (m.map) m.map.dispose();
m.dispose();
});
} else {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
}
});
// 如果 splatMesh 自身有 dispose 方法,也调用一下
if (typeof splatMesh.dispose === 'function') {
splatMesh.dispose();
}
console.log("旧模型已移除,资源已清理");
}
// 2. 创建新模型
const newMesh = new SplatMesh({
url: url,
onLoad: (mesh) => {
console.log(`新模型加载完成: ${url}`);
// 旋转修正(与原始代码保持一致)
mesh.rotation.x = -Math.PI;
if (autoFit) {
fitCameraToModel();
const state = getDeviceType();
const targetZoom = state === "desktop" ? desktopZoom : mobileZoom;
animateZoomTo(targetZoom, zoomDuration);
}
controls.update();
if (onComplete) onComplete(mesh);
},
onProgress: (event) => {
if (event.lengthComputable) {
const pct = ((event.loaded / event.total) * 100).toFixed(1);
console.log(`加载进度: ${pct}%`);
}
if (onProgress) onProgress(event);
},
onError: (err) => {
console.error(`模型加载失败: ${url}`, err);
if (onError) onError(err);
}
});
// 3. 添加到场景并更新引用
scene.add(newMesh);
// 更新全局引用(用于调试和后续操作)
splatMesh = newMesh;
window.__splat = splatMesh;
return newMesh;
}
// 暴露到全局
window.loadNewModel = loadNewModel;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7. 相机适配
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function getModelBounds() {
if (!splatMesh) return null;
const box = new THREE.Box3();
splatMesh.traverse((child) => {
if (child.isMesh && child.geometry) {
box.expandByObject(child);
} else if (child.isPoints && child.geometry) {
box.expandByObject(child);
}
});
if (box.isEmpty()) {
box.setFromObject(splatMesh);
}
return box;
}
function fitPerspectiveToModel() {
const box = getModelBounds();
console.log("模型包围盒:", box);
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认范围");
const size = 0.3;
const dist = size / (2 * Math.tan((perspCamera.fov * Math.PI) / 360));
perspCamera.position.set(0, 0, dist * 3);
controls.target.set(0, 0, 0);
controls.update();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
controls.target.copy(center);
const fovRad = (perspCamera.fov * Math.PI) / 180;
const dist = maxDim / (2 * Math.tan(fovRad / 2)) * 0.25;
// ★ 修复:从 Z 轴方向观察(标准前视图)
const newPos = center.clone();
newPos.z += Math.max(dist, 0.1);
perspCamera.position.copy(newPos);
perspCamera.lookAt(center);
controls.update();
controls.maxDistance = dist * 3;
controls.minDistance = maxDim * 0.1;
HOME_POS.copy(perspCamera.position);
HOME_TARGET.copy(controls.target);
}
function fitCameraToModel() {
fitPerspectiveToModel();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 8. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
// 暴露到全局供调试
window.__scene = scene;
window.__camera = camera;
window.__controls = controls;
// 平滑过渡 zoom
function animateZoomTo(targetZoom, duration = 500) {
if (zoomAnimId) cancelAnimationFrame(zoomAnimId);
const startZoom = camera.zoom;
const startTime = performance.now();
function step(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.zoom = startZoom + (targetZoom - startZoom) * ease;
camera.updateProjectionMatrix();
if (t < 1) {
zoomAnimId = requestAnimationFrame(step);
} else {
zoomAnimId = null;
updateInfo();
}
}
zoomAnimId = requestAnimationFrame(step);
}
function updateInfo() {
const p = camera.position;
const t = controls.target;
const r = camera.rotation;
console.log(`相机位置: (${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)})`);
console.log(`旋转中心: (${t.x.toFixed(2)}, ${t.y.toFixed(2)}, ${t.z.toFixed(2)})`);
}
function getDeviceType() {
const ua = navigator.userAgent;
const isTablet = /iPad|Android(?!.*Mobile)/i.test(ua) ||
(/Android/i.test(ua) && window.innerWidth > 768);
if (/Mobi|Android|iPhone/i.test(ua) && !isTablet) return 'mobile';
if (isTablet) return 'tablet';
return 'desktop';
}
window.addEventListener("resize", () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D高斯泼溅 - 视角探索</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
overflow: hidden;
background: #1a1a2e;
font-family: 'Segoe UI', Arial, sans-serif;
}
canvas {
display: block;
}
/* ── 顶部提示栏 ── */
.hint {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
padding: 8px 20px;
border-radius: 30px;
font-size: 14px;
letter-spacing: 0.3px;
border: 1px solid rgba(255, 255, 255, 0.08);
user-select: none;
pointer-events: none;
white-space: nowrap;
z-index: 10;
}
.hint strong {
color: #ffd700;
}
/* ── 控制面板 ── */
.panel {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
padding: 12px 20px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 10;
max-width: 90vw;
}
.panel button {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #ddd;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
letter-spacing: 0.3px;
white-space: nowrap;
}
.panel button:hover {
background: rgba(255, 215, 0, 0.2);
border-color: #ffd700;
color: #fff;
transform: scale(1.04);
}
.panel button.active {
background: rgba(255, 215, 0, 0.25);
border-color: #ffd700;
color: #ffd700;
}
.panel .sep {
width: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 4px 6px;
}
.panel .label {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
display: flex;
align-items: center;
margin-right: 4px;
}
/* ── 信息输出 ── */
.info {
position: fixed;
bottom: 100px;
right: 24px;
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
font-family: 'Courier New', monospace;
text-align: right;
line-height: 1.6;
background: rgba(0, 0, 0, 0.3);
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
z-index: 10;
pointer-events: none;
user-select: none;
max-width: 300px;
word-break: break-all;
}
.info .highlight {
color: #ffd700;
}
.info .rot-row {
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 4px;
}
@media (max-width: 700px) {
.panel {
padding: 10px 14px;
gap: 6px;
bottom: 16px;
}
.panel button {
font-size: 12px;
padding: 4px 12px;
}
.hint {
font-size: 12px;
padding: 4px 14px;
top: 10px;
white-space: normal;
}
.info {
display: none;
}
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js"
}
}
</script>
<base target="_blank">
</head>
<body>
<!-- 控制面板 -->
<div class="panel" id="panel" style="display:none">
<span class="label">📐 视角</span>
<button data-view="front">正面</button>
<button data-view="back">背面</button>
<button data-view="left">左侧</button>
<button data-view="right">右侧</button>
<button data-view="top">俯视</button>
<button data-view="bottom">仰视</button>
<button data-view="home">复位</button>
<div class="sep"></div>
<button id="btnAutoRotate">🔄 自转</button>
<button id="btnFit">📦 适配</button>
<button id="btnProjection" title="切换透视/正交投影">🔲 正交</button>
<button id="btnAxes">🎯 坐标轴</button>
<button id="btnSave">💾 保存视角</button>
<button id="btnLoad">📂 恢复视角</button>
<button id="btnProjection" title="切换透视/正交投影">🔲 正交</button>
<!-- ▼ 新增 ▼ -->
<button id="btnAutoCrop" title="自动放大裁剪,去除画面四周留白">✂️ 去留白</button>
</div>
<!-- 信息 -->
<div class="info" id="info" style="display: none;">
<div>相机: <span id="camPos" class="highlight"></span></div>
<div>目标: <span id="camTarget" class="highlight"></span></div>
<div>投影: <span id="camProj" class="highlight">透视</span></div>
<div class="rot-row">旋转: <span id="camRot" class="highlight"></span></div>
<div style="margin-top:4px; opacity:0.5; font-size:11px;">
💡 控制台输入 <span style="color:#ffd700;">getView()</span> 获取完整视角信息
</div>
</div>
<script type="module">
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1. 导入
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { AxesHelper } from "three";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 场景 / 相机 / 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const aspect = window.innerWidth / window.innerHeight;
// ── 透视相机 ──
const perspCamera = new THREE.PerspectiveCamera(50, aspect, 0.01, 1000);
perspCamera.position.set(-0.034, 0.331, -1.972);
// ── 正交相机 ──
const frustum = 5;
const orthoCamera = new THREE.OrthographicCamera(
-frustum * aspect, frustum * aspect,
frustum, -frustum,
0.01, 1000
);
orthoCamera.position.copy(perspCamera.position);
// 当前激活的相机
let useOrthographic = false;
let camera = perspCamera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 轨道控制器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// controls.minDistance = 0.3;
controls.maxDistance = 0.3;
// 透视模式:滚轮缩放距离限制
// controls.minDistance = 1.8;
// controls.maxDistance = 5.0;
// 正交模式:滚轮缩放倍率限制
controls.minZoom = 0.2;
controls.maxZoom = 3.0;
controls.target.set(0, 0, 0);
// 限制转动范围:上下左右各10度
controls.minPolarAngle = Math.PI / 2 - Math.PI / 18;
controls.maxPolarAngle = Math.PI / 2 + Math.PI / 18;
controls.minAzimuthAngle = -Math.PI / 18;
controls.maxAzimuthAngle = Math.PI / 18;
controls.update();
// 保存初始视角
const HOME_POS = perspCamera.position.clone();
const HOME_TARGET = controls.target.clone();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. Spark 渲染器
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const spark = new SparkRenderer({ renderer });
scene.add(spark);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 5. 加载 3DGS 模型
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const SPLAT_URL = "./A11.spz";
const splatMesh = new SplatMesh({ url: SPLAT_URL });
scene.add(splatMesh);
let modelReady = false;
modelReady = true;
splatMesh.rotation.x = -Math.PI;
requestAnimationFrame(() => {
fitCameraToModel();
updateInfo();
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 6. 辅助工具
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let axesHelper = null;
let axesVisible = false;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7. 核心功能函数
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function getModelBounds() {
if (!splatMesh) return null;
const box = new THREE.Box3();
splatMesh.traverse((child) => {
if (child.isMesh && child.geometry) {
box.expandByObject(child);
} else if (child.isPoints && child.geometry) {
box.expandByObject(child);
}
});
if (box.isEmpty()) {
box.setFromObject(splatMesh);
}
return box;
}
// ── ★ 正交投影自动适配:根据模型包围盒精确计算 left/right/top/bottom ──
function fitOrthographicToModel() {
const box = getModelBounds();
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认正交范围");
resetOrthographicDefaults();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
// 相机坐标系的三个正交基向量
const camPos = camera.position.clone();
const forward = new THREE.Vector3().subVectors(controls.target, camPos).normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
if (right.lengthSq() < 0.0001) right.set(1, 0, 0);
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
// 获取包围盒 8 个顶点,投影到相机视平面
const corners = [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z),
];
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const corner of corners) {
const toCorner = new THREE.Vector3().subVectors(corner, controls.target);
const projX = toCorner.dot(right);
const projY = toCorner.dot(up);
const projZ = toCorner.dot(forward);
minX = Math.min(minX, projX); maxX = Math.max(maxX, projX);
minY = Math.min(minY, projY); maxY = Math.max(maxY, projY);
minZ = Math.min(minZ, projZ); maxZ = Math.max(maxZ, projZ);
}
// 计算需要的半宽/半高(留 2% padding 避免贴边)
const padding = 1.02;
const halfW = Math.max(Math.abs(minX), Math.abs(maxX)) * padding;
const halfH = Math.max(Math.abs(minY), Math.abs(maxY)) * padding;
// 根据宽高比调整,确保模型完整显示且不留白
const viewAspect = window.innerWidth / window.innerHeight;
let finalLeft, finalRight, finalTop, finalBottom;
if (halfW / halfH > viewAspect) {
// 宽度主导:以宽度为基准
finalLeft = -halfW;
finalRight = halfW;
finalTop = halfW / viewAspect;
finalBottom = -halfW / viewAspect;
} else {
// 高度主导:以高度为基准
finalLeft = -halfH * viewAspect;
finalRight = halfH * viewAspect;
finalTop = halfH;
finalBottom = -halfH;
}
orthoCamera.left = finalLeft;
orthoCamera.right = finalRight;
orthoCamera.top = finalTop;
orthoCamera.bottom = finalBottom;
// 深度范围:确保模型在可视范围内
orthoCamera.near = Math.max(0.001, minZ - 1.0);
orthoCamera.far = Math.max(10.0, maxZ + 1.0);
orthoCamera.updateProjectionMatrix();
}
function resetOrthographicDefaults() {
const frustum = 5;
const a = window.innerWidth / window.innerHeight;
orthoCamera.left = -frustum * a;
orthoCamera.right = frustum * a;
orthoCamera.top = frustum;
orthoCamera.bottom = -frustum;
orthoCamera.near = 0.01;
orthoCamera.far = 1000;
orthoCamera.updateProjectionMatrix();
}
// ── 通用适配入口 ──
function fitCameraToModel() {
if (useOrthographic) {
fitOrthographicToModel();
} else {
fitPerspectiveToModel();
}
}
function fitPerspectiveToModel() {
const box = getModelBounds();
if (!box || box.isEmpty()) {
console.warn("无法计算包围盒,使用默认范围");
const size = 0.1;
const dist = size / (2 * Math.tan((perspCamera.fov * Math.PI) / 360));
perspCamera.position.set(0, 0, dist * 1.3);
controls.target.set(0, 0, 0);
controls.update();
return;
}
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
const fovRad = (perspCamera.fov * Math.PI) / 180;
const dist = maxDim / (2 * Math.tan(fovRad / 2)) * 1.2;
const dir = perspCamera.position.clone().sub(controls.target).normalize();
const newPos = center.clone().add(dir.multiplyScalar(Math.max(dist, 0.5)));
perspCamera.position.copy(newPos);
controls.target.copy(center);
controls.update();
}
// ── ★ 正交/透视切换 ──
function toggleProjection() {
useOrthographic = !useOrthographic;
const oldCam = camera;
camera = useOrthographic ? orthoCamera : perspCamera;
// 保持位置和朝向完全一致
camera.position.copy(oldCam.position);
camera.quaternion.copy(oldCam.quaternion);
// 更新控制器
controls.object = camera;
controls.update();
// 正交模式下自动裁剪适配
if (useOrthographic) {
fitOrthographicToModel();
}
// 更新按钮状态
const btn = document.getElementById('btnProjection');
btn.classList.toggle('active', useOrthographic);
btn.textContent = useOrthographic ? '🔲 正交' : '👁 透视';
updateInfo();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
console.log(useOrthographic ? '🔲 已切换为正交投影' : '👁 已切换为透视投影');
}
function flyTo(pos, target, duration = 600) {
const startPos = camera.position.clone();
const startTarget = controls.target.clone();
const endPos = pos.clone();
const endTarget = target.clone();
const startTime = performance.now();
function animateFly(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.position.lerpVectors(startPos, endPos, ease);
controls.target.lerpVectors(startTarget, endTarget, ease);
controls.update();
if (t < 1) {
requestAnimationFrame(animateFly);
} else {
camera.position.copy(endPos);
controls.target.copy(endTarget);
controls.update();
if (useOrthographic) fitOrthographicToModel();
updateInfo();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
}
}
requestAnimationFrame(animateFly);
}
function setView(name) {
const box = getModelBounds();
const target = new THREE.Vector3(0, 0, 0);
let maxDim = 3;
if (box && !box.isEmpty()) {
box.getCenter(target);
const size = new THREE.Vector3();
box.getSize(size);
maxDim = Math.max(size.x, size.y, size.z);
}
if (useOrthographic) {
// 正交模式下:固定标准距离,靠包围盒计算投影范围
const dist = Math.max(maxDim * 1.5, 1.0);
let pos;
switch (name) {
case 'front': pos = new THREE.Vector3(0, 0, dist); break;
case 'back': pos = new THREE.Vector3(0, 0, -dist); break;
case 'left': pos = new THREE.Vector3(-dist, 0, 0); break;
case 'right': pos = new THREE.Vector3(dist, 0, 0); break;
case 'top': pos = new THREE.Vector3(0, dist, 0.01); break;
case 'bottom': pos = new THREE.Vector3(0, -dist, 0.01); break;
case 'home':
flyTo(HOME_POS, HOME_TARGET);
return;
default: return;
}
flyTo(pos, target);
} else {
// 透视模式:根据 FOV 计算距离
const fovRad = (perspCamera.fov * Math.PI) / 180;
const baseDist = maxDim / (2 * Math.tan(fovRad / 2)) * 1.3;
const d = Math.max(Math.min(baseDist, 8), 0.8);
let pos;
switch (name) {
case 'front': pos = new THREE.Vector3(0, 0, d); break;
case 'back': pos = new THREE.Vector3(0, 0, -d); break;
case 'left': pos = new THREE.Vector3(-d, 0, 0); break;
case 'right': pos = new THREE.Vector3(d, 0, 0); break;
case 'top': pos = new THREE.Vector3(0, d, 0.01); break;
case 'bottom': pos = new THREE.Vector3(0, -d, 0.01); break;
case 'home':
flyTo(HOME_POS, HOME_TARGET);
return;
default: return;
}
flyTo(pos, target);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7.1 ★ 更新信息
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 7.5 ★ 自动去留白(Auto-Crop)
// 原理:相机位置不动,像素探测 + 二分搜索 camera.zoom
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const PROBE_SIZE = 96; // 探测分辨率(越大越精确,越小越快)
const EDGE_RING = 2; // 检测最外圈几个像素
const ALPHA_SOLID = 40; // alpha 低于此值视为"留白"(调大可裁掉半透明毛边,如 120~180)
const MAX_ZOOM = 40; // 放大倍率上限
const KEY_R = 255, KEY_G = 0, KEY_B = 255; // 背景色键(品红,泼溅内容几乎不会撞色)
const probeRT = new THREE.WebGLRenderTarget(PROBE_SIZE, PROBE_SIZE);
const probeBuf = new Uint8Array(PROBE_SIZE * PROBE_SIZE * 4);
let autoCropEnabled = false;
let zoomAnimId = null;
// 以指定 zoom 渲染一帧到离屏小图并读回像素
function renderProbe(zoomValue) {
const oldZoom = camera.zoom;
const oldBg = scene.background;
const oldColor = new THREE.Color();
renderer.getClearColor(oldColor);
const oldAlpha = renderer.getClearAlpha();
camera.zoom = zoomValue;
camera.updateProjectionMatrix();
scene.background = null; // 不画背景色
renderer.setRenderTarget(probeRT);
renderer.setClearColor(new THREE.Color(KEY_R/255, KEY_G/255, KEY_B/255), 0);
renderer.clear(true, true, true);
renderer.render(scene, camera);
renderer.readRenderTargetPixels(probeRT, 0, 0, PROBE_SIZE, PROBE_SIZE, probeBuf);
renderer.setRenderTarget(null);
// 还原现场
scene.background = oldBg;
renderer.setClearColor(oldColor, oldAlpha);
camera.zoom = oldZoom;
camera.updateProjectionMatrix();
return probeBuf;
}
// 判断某个像素是否为"背景/留白"
// 依据:alpha 很低,或颜色非常接近我们设定的 key 色
function isBackgroundPixel(buf, idx) {
const a = buf[idx + 3];
if (a < ALPHA_SOLID) return true;
const r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
const dr = r - KEY_R, dg = g - KEY_G, db = b - KEY_B;
const distSq = dr * dr + dg * dg + db * db;
return distSq < 900; // 颜色距离阈值,防止 key 色泄露判定失误
}
// 检测某个 zoom 值下,画面四周是否还存在留白
function hasEdgeBackground(zoomValue) {
const buf = renderProbe(zoomValue);
const w = PROBE_SIZE, h = PROBE_SIZE;
const checkPixel = (x, y) => {
const idx = (y * w + x) * 4;
return isBackgroundPixel(buf, idx);
};
// 扫描最外圈 EDGE_RING 层像素
for (let ring = 0; ring < EDGE_RING; ring++) {
for (let x = ring; x < w - ring; x++) {
if (checkPixel(x, ring)) return true; // 上边
if (checkPixel(x, h - 1 - ring)) return true; // 下边
}
for (let y = ring; y < h - ring; y++) {
if (checkPixel(ring, y)) return true; // 左边
if (checkPixel(w - 1 - ring, y)) return true; // 右边
}
}
return false;
}
// 二分搜索:找到"刚好没有留白"的最小 zoom
function findMinZoomNoBackground() {
let lo = 1.0;
let hi = MAX_ZOOM;
// 先确认上限是否足够(极端情况:模型很小、视角刁钻)
if (hasEdgeBackground(hi)) {
console.warn('⚠️ 已达最大放大倍率仍有留白,可能视角朝向了模型外部空白区域');
return hi;
}
// 若初始 zoom=1 就已经没有留白,直接返回
if (!hasEdgeBackground(lo)) {
return lo;
}
const ITER = 14; // 二分次数,14 次已足够精细(1/2^14)
for (let i = 0; i < ITER; i++) {
const mid = (lo + hi) / 2;
if (hasEdgeBackground(mid)) {
lo = mid; // 还有留白,需要继续放大
} else {
hi = mid; // 没留白了,尝试缩小一点看临界
}
}
return hi * 1.01; // 留一点点余量,避免临界抖动导致又露出留白
}
// 平滑过渡到目标 zoom
function animateZoomTo(targetZoom, duration = 500) {
if (zoomAnimId) cancelAnimationFrame(zoomAnimId);
const startZoom = camera.zoom;
const startTime = performance.now();
function step(time) {
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
camera.zoom = startZoom + (targetZoom - startZoom) * ease;
camera.updateProjectionMatrix();
if (t < 1) {
zoomAnimId = requestAnimationFrame(step);
} else {
zoomAnimId = null;
updateInfo();
}
}
zoomAnimId = requestAnimationFrame(step);
}
// 对外入口:执行一次自动去留白
function performAutoCrop() {
if (!splatMesh) return;
const targetZoom = findMinZoomNoBackground();
console.log(targetZoom)
animateZoomTo(targetZoom);
console.log(`✂️ 自动去留白完成,zoom = ${targetZoom.toFixed(3)}`);
}
// 开关:开启后,相机每次停止拖动会自动重新计算裁剪
window.toggleAutoCrop = () => {
autoCropEnabled = !autoCropEnabled;
const btn = document.getElementById('btnAutoCrop');
btn.classList.toggle('active', autoCropEnabled);
if (autoCropEnabled) {
performAutoCrop();
console.log('✂️ 自动去留白:已开启(拖动结束会自动重新裁剪)');
} else {
// 关闭时恢复 zoom = 1
animateZoomTo(1.0);
console.log('✂️ 自动去留白:已关闭');
}
};
window.performAutoCrop = performAutoCrop;
function updateInfo() {
const p = camera.position;
const t = controls.target;
document.getElementById('camPos').textContent =
`${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)}`;
document.getElementById('camTarget').textContent =
`${t.x.toFixed(3)}, ${t.y.toFixed(3)}, ${t.z.toFixed(3)}`;
document.getElementById('camProj').textContent =
useOrthographic ? '正交 (Orthographic)' : '透视 (Perspective)';
const r = camera.rotation;
const degX = (r.x * 180 / Math.PI);
const degY = (r.y * 180 / Math.PI);
const degZ = (r.z * 180 / Math.PI);
document.getElementById('camRot').textContent =
`${degX.toFixed(1)}°, ${degY.toFixed(1)}°, ${degZ.toFixed(1)}°`;
}
setInterval(updateInfo, 300);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 8. ★ 全局辅助函数
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
window.getView = () => {
const p = camera.position;
const t = controls.target;
const r = camera.rotation;
const q = camera.quaternion;
const dir = new THREE.Vector3().copy(t).sub(p).normalize();
let azimuth = null;
let polar = null;
try {
azimuth = controls.getAzimuthalAngle ? controls.getAzimuthalAngle() : null;
polar = controls.getPolarAngle ? controls.getPolarAngle() : null;
} catch (_) { }
return {
projection: useOrthographic ? 'orthographic' : 'perspective',
position: [p.x, p.y, p.z],
target: [t.x, t.y, t.z],
direction: [dir.x, dir.y, dir.z],
euler: {
rad: { x: r.x, y: r.y, z: r.z },
deg: {
x: r.x * 180 / Math.PI,
y: r.y * 180 / Math.PI,
z: r.z * 180 / Math.PI
}
},
quaternion: [q.x, q.y, q.z, q.w],
orbit: {
azimuth: azimuth !== null ? azimuth * 180 / Math.PI : null,
polar: polar !== null ? polar * 180 / Math.PI : null
},
toString() {
const lines = [
`🔲 投影: ${useOrthographic ? '正交 (Orthographic)' : '透视 (Perspective)'}`,
`📍 位置: (${p.x.toFixed(4)}, ${p.y.toFixed(4)}, ${p.z.toFixed(4)})`,
`🎯 目标: (${t.x.toFixed(4)}, ${t.y.toFixed(4)}, ${t.z.toFixed(4)})`,
`🧭 方向: (${dir.x.toFixed(4)}, ${dir.y.toFixed(4)}, ${dir.z.toFixed(4)})`,
`🔄 旋转 (deg): X=${(r.x * 180 / Math.PI).toFixed(2)}°, Y=${(r.y * 180 / Math.PI).toFixed(2)}°, Z=${(r.z * 180 / Math.PI).toFixed(2)}°`,
`📐 四元数: (${q.x.toFixed(4)}, ${q.y.toFixed(4)}, ${q.z.toFixed(4)}, ${q.w.toFixed(4)})`,
];
if (azimuth !== null && polar !== null) {
lines.push(`🌐 方位角: ${(azimuth * 180 / Math.PI).toFixed(2)}°, 极角: ${(polar * 180 / Math.PI).toFixed(2)}°`);
}
return lines.join('\n');
}
};
};
window.getCameraRotation = () => {
const r = camera.rotation;
const q = camera.quaternion;
const deg = {
x: r.x * 180 / Math.PI,
y: r.y * 180 / Math.PI,
z: r.z * 180 / Math.PI
};
return {
euler: { rad: { x: r.x, y: r.y, z: r.z }, deg },
quaternion: [q.x, q.y, q.z, q.w],
toString() {
return `欧拉角 (deg): X=${deg.x.toFixed(2)}°, Y=${deg.y.toFixed(2)}°, Z=${deg.z.toFixed(2)}°\n` +
`欧拉角 (rad): X=${r.x.toFixed(4)}, Y=${r.y.toFixed(4)}, Z=${r.z.toFixed(4)}\n` +
`四元数: (${q.x.toFixed(4)}, ${q.y.toFixed(4)}, ${q.z.toFixed(4)}, ${q.w.toFixed(4)})`;
}
};
};
window.saveView = () => {
const data = {
pos: camera.position.toArray(),
target: controls.target.toArray(),
orthographic: useOrthographic
};
localStorage.setItem('savedView', JSON.stringify(data));
console.log('✅ 视角已保存到 localStorage');
};
window.loadView = () => {
const raw = localStorage.getItem('savedView');
if (!raw) { console.warn('⚠️ 没有保存的视角'); return; }
try {
const data = JSON.parse(raw);
const pos = new THREE.Vector3(...data.pos);
const target = new THREE.Vector3(...data.target);
// 如果保存的是正交视角,先切换
if (data.orthographic && !useOrthographic) {
toggleProjection();
} else if (!data.orthographic && useOrthographic) {
toggleProjection();
}
flyTo(pos, target);
console.log('✅ 视角已恢复');
} catch (e) {
console.warn('⚠️ 恢复视角失败', e);
}
};
let autoRotate = false;
window.toggleAutoRotate = () => {
autoRotate = !autoRotate;
document.getElementById('btnAutoRotate').classList.toggle('active', autoRotate);
console.log(autoRotate ? '🔄 自动旋转 开启' : '⏸ 自动旋转 关闭');
};
window.toggleAxes = () => {
if (!axesHelper) {
axesHelper = new AxesHelper(1.2);
scene.add(axesHelper);
}
axesVisible = !axesVisible;
axesHelper.visible = axesVisible;
document.getElementById('btnAxes').classList.toggle('active', axesVisible);
console.log(axesVisible ? '🎯 坐标轴 显示' : '🎯 坐标轴 隐藏');
};
window.toggleProjection = toggleProjection;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 9. 绑定 UI 事件
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.querySelectorAll('[data-view]').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
setView(view);
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
});
document.getElementById('btnAutoCrop').addEventListener('click', window.toggleAutoCrop);
document.getElementById('btnAutoRotate').addEventListener('click', window.toggleAutoRotate);
document.getElementById('btnFit').addEventListener('click', () => {
fitCameraToModel();
if (autoCropEnabled) performAutoCrop(); // ★ 新增
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
document.getElementById('btnProjection').addEventListener('click', toggleProjection);
document.getElementById('btnAxes').addEventListener('click', window.toggleAxes);
document.getElementById('btnSave').addEventListener('click', () => {
window.saveView();
const el = document.getElementById('btnSave');
el.textContent = '✅ 已保存';
setTimeout(() => { el.textContent = '💾 保存视角'; }, 1200);
});
document.getElementById('btnLoad').addEventListener('click', () => {
window.loadView();
if (autoRotate) {
autoRotate = false;
document.getElementById('btnAutoRotate').classList.remove('active');
}
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 10. 窗口自适应
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
const a = w / h;
if (useOrthographic) {
// 正交相机:保持投影范围比例,根据宽高比调整
const halfH = orthoCamera.top;
orthoCamera.left = -halfH * a;
orthoCamera.right = halfH * a;
orthoCamera.updateProjectionMatrix();
} else {
perspCamera.aspect = a;
perspCamera.updateProjectionMatrix();
}
renderer.setSize(w, h);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 11. 键盘快捷键
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.addEventListener('keydown', (e) => {
switch (e.key.toLowerCase()) {
case '1': setView('front'); break;
case '2': setView('back'); break;
case '3': setView('left'); break;
case '4': setView('right'); break;
case '5': setView('top'); break;
case '6': setView('bottom'); break;
case '0': setView('home'); break;
case 'r': window.toggleAutoRotate(); break;
case 'f': fitCameraToModel(); break;
case 'p': toggleProjection(); break;
case 'a': window.toggleAxes(); break;
case 's': window.saveView(); break;
case 'l': window.loadView(); break;
case 'i':
console.log('📐 相机旋转角度:\n' + window.getCameraRotation().toString());
break;
case 'v':
console.log('📐 完整视角信息:\n' + window.getView().toString());
break;
default: break;
}
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 12. 渲染循环
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.setAnimationLoop(() => {
if (autoRotate && splatMesh) {
splatMesh.rotation.y += 0.006;
}
controls.update();
controls.addEventListener('end', () => {
if (!autoCropEnabled) return;
clearTimeout(cropDebounceTimer);
cropDebounceTimer = setTimeout(() => {
performAutoCrop();
}, 150); // 拖动松手后延迟一点再算,避免频繁触发
});
renderer.render(scene, camera);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 13. 启动后自动适配
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setTimeout(() => {
if (modelReady) {
fitCameraToModel();
} else {
splatMesh.addEventListener("load", () => {
modelReady = true;
fitCameraToModel();
});
}
animateZoomTo(2.2,0);
}, 500);
console.log('🚀 3D高斯泼溅查看器已启动');
console.log('📖 快捷键: 1-6 视角 | 0 复位 | R 自转 | F 适配 | P 切换投影 | A 坐标轴 | S 保存 | L 恢复');
console.log('📐 按 I 键查看相机旋转角度,按 V 键查看完整视角信息');
console.log('💡 在控制台输入 getView() 获取完整视角信息');
console.log('💡 在控制台输入 toggleProjection() 切换正交/透视投影');
window.__scene = scene;
window.__camera = camera;
window.__controls = controls;
window.__splat = splatMesh;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 14. 双击输出视角
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
renderer.domElement.addEventListener('dblclick', () => {
console.log('📐 当前视角:\n' + window.getView().toString());
});
console.log('✅ 双击画面可在控制台输出当前视角');
</script>
</body>
</html>
\ No newline at end of file
<!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