mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
Simplify viewer. Move old one to editor
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Viewer</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none; /* Remove default focus outline */
|
||||
}
|
||||
#main-gui {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
#second-gui {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
}
|
||||
#progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #45a049);
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="progress-bar">
|
||||
<div id="progress-fill"></div>
|
||||
</div>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js",
|
||||
"lil-gui": "../node_modules/lil-gui/dist/lil-gui.esm.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<canvas id="canvas" tabindex="0"></canvas>
|
||||
<div id="main-gui"></div>
|
||||
<div id="second-gui"></div>
|
||||
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat" multiple="true" style="display: none;" />
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import { GUI } from "lil-gui";
|
||||
import { constructGrid, ForgeControls, ForgeRenderer, SplatMesh, textSplats, dyno, transcodeSpz } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(0, 0, 1);
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const renderer = new THREE.WebGLRenderer({ canvas });
|
||||
const forge = new ForgeRenderer({ renderer });
|
||||
|
||||
function handleResize() {
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
const frame = new THREE.Group();
|
||||
frame.quaternion.set(1, 0, 0, 0);
|
||||
scene.add(frame);
|
||||
|
||||
// Keep track of original file bytes for each loaded splat mesh
|
||||
const inputs = [];
|
||||
|
||||
const grid = new SplatMesh({
|
||||
constructSplats: (splats) => constructGrid({
|
||||
splats,
|
||||
extents: new THREE.Box3(new THREE.Vector3(-10, -10, -10), new THREE.Vector3(10, 10, 10)),
|
||||
}),
|
||||
});
|
||||
grid.opacity = 0;
|
||||
scene.add(grid);
|
||||
|
||||
const gui = new GUI({
|
||||
title: "Settings",
|
||||
container: document.getElementById("main-gui")
|
||||
}).close();
|
||||
const secondGui = new GUI({
|
||||
title: "Splats",
|
||||
container: document.getElementById("second-gui")
|
||||
}).close();
|
||||
|
||||
const controls = new ForgeControls({ canvas });
|
||||
|
||||
// Setup mouse controls to orbit the camera around
|
||||
const orbitControls = new OrbitControls(camera, renderer.domElement);
|
||||
orbitControls.enabled = false;
|
||||
orbitControls.target.set(0, 0, 0);
|
||||
orbitControls.minDistance = 0.1;
|
||||
orbitControls.maxDistance = 10;
|
||||
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
fileInput.onchange = (event) => {
|
||||
loadFiles([...event.target.files]);
|
||||
};
|
||||
|
||||
const guiOptions = {
|
||||
highDevicePixel: false,
|
||||
resetOnLoad: true,
|
||||
loadOffset: 0,
|
||||
openCv: true,
|
||||
autoRotate: true,
|
||||
orbit: false,
|
||||
reversePointerDir: false,
|
||||
reversePointerSlide: false,
|
||||
openFiles: () => {
|
||||
fileInput.click();
|
||||
},
|
||||
loadFromText: "",
|
||||
loadFromTextAction: () => {
|
||||
if (guiOptions.loadFromText.trim()) {
|
||||
const urls = parseURLsFromText(guiOptions.loadFromText);
|
||||
if (urls.length > 0) {
|
||||
loadFiles(urls);
|
||||
guiOptions.loadFromText = ""; // Clear after loading
|
||||
} else {
|
||||
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, or .ksplat");
|
||||
}
|
||||
}
|
||||
},
|
||||
resetPose: () => {
|
||||
camera.position.set(0, 0, 1);
|
||||
camera.quaternion.set(0, 0, 0, 1);
|
||||
camera.fov = 75;
|
||||
resetFrameQuaternion();
|
||||
camera.updateProjectionMatrix();
|
||||
},
|
||||
};
|
||||
|
||||
function resetFrameQuaternion() {
|
||||
if (guiOptions.openCv) {
|
||||
frame.quaternion.set(1, 0, 0, 0);
|
||||
} else {
|
||||
frame.quaternion.set(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse URL query parameters for URLs to load
|
||||
function parseURLsFromQuery() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urls = [];
|
||||
|
||||
// Get all "url" parameters
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
if (key === "url") {
|
||||
urls.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Parse and apply camera parameters from URL
|
||||
function applyCameraFromQuery() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Position parameters
|
||||
const x = urlParams.get('x');
|
||||
const y = urlParams.get('y');
|
||||
const z = urlParams.get('z');
|
||||
|
||||
if (x !== null || y !== null || z !== null) {
|
||||
camera.position.set(
|
||||
x !== null ? parseFloat(x) : camera.position.x,
|
||||
y !== null ? parseFloat(y) : camera.position.y,
|
||||
z !== null ? parseFloat(z) : camera.position.z
|
||||
);
|
||||
}
|
||||
|
||||
// Quaternion parameters
|
||||
const qx = urlParams.get('qx');
|
||||
const qy = urlParams.get('qy');
|
||||
const qz = urlParams.get('qz');
|
||||
const qw = urlParams.get('qw');
|
||||
|
||||
if (qx !== null || qy !== null || qz !== null || qw !== null) {
|
||||
camera.quaternion.set(
|
||||
qx !== null ? parseFloat(qx) : camera.quaternion.x,
|
||||
qy !== null ? parseFloat(qy) : camera.quaternion.y,
|
||||
qz !== null ? parseFloat(qz) : camera.quaternion.z,
|
||||
qw !== null ? parseFloat(qw) : camera.quaternion.w
|
||||
).normalize();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply camera parameters from URL
|
||||
guiOptions.resetPose();
|
||||
applyCameraFromQuery();
|
||||
|
||||
const cameraFolder = gui.addFolder("Camera");
|
||||
cameraFolder.add(camera.position, "x", -10, 10, 0.01).name("X").listen();
|
||||
cameraFolder.add(camera.position, "y", -10, 10, 0.01).name("Y").listen();
|
||||
cameraFolder.add(camera.position, "z", -10, 10, 0.01).name("Z").listen();
|
||||
const rotX = cameraFolder.add(camera.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
|
||||
const rotY = cameraFolder.add(camera.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
|
||||
const rotZ = cameraFolder.add(camera.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
|
||||
cameraFolder.add(camera, "fov", 1, 179, 1).name("Fov Y degrees").listen().onChange((value) => {
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
// Progress bar functions
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
|
||||
function showProgress() {
|
||||
progressBar.style.display = "block";
|
||||
progressFill.style.width = "0%";
|
||||
}
|
||||
|
||||
function updateProgress(progress) {
|
||||
progressFill.style.width = `${Math.min(100, Math.max(0, progress * 100))}%`;
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
progressBar.style.display = "none";
|
||||
}
|
||||
|
||||
// Calculate progress when total size is unknown
|
||||
function calculateUnknownProgress(loadedBytes) {
|
||||
const midpointMB = 10 * 1024 * 1024; // 10 MB in bytes
|
||||
return loadedBytes / (loadedBytes + midpointMB);
|
||||
}
|
||||
|
||||
async function fetchWithProgress(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: "cors",
|
||||
cache: "default",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : null;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let loadedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
loadedBytes += value.length;
|
||||
|
||||
// Calculate progress
|
||||
let progress;
|
||||
if (total) {
|
||||
progress = loadedBytes / total;
|
||||
} else {
|
||||
progress = calculateUnknownProgress(loadedBytes);
|
||||
}
|
||||
|
||||
updateProgress(progress);
|
||||
}
|
||||
|
||||
// Combine all chunks into a single Uint8Array
|
||||
const allChunks = new Uint8Array(loadedBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
allChunks.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return allChunks.buffer;
|
||||
} catch (error) {
|
||||
// Hide progress bar on error
|
||||
hideProgress();
|
||||
|
||||
// Detect CORS issues
|
||||
if (error instanceof TypeError && (
|
||||
error.message.includes('Failed to fetch') ||
|
||||
error.message.includes('Network request failed') ||
|
||||
error.message.includes('CORS') ||
|
||||
error.message.includes('cross-origin')
|
||||
)) {
|
||||
alert(`CORS Error: Cannot load file from ${url}\n\nThe server doesn't allow cross-origin requests. The file needs to be:\n• Served from the same domain, or\n• The server must include CORS headers like:\n Access-Control-Allow-Origin: *`);
|
||||
} else if (error.message.includes('HTTP error! status:')) {
|
||||
const status = error.message.match(/status: (\d+)/)?.[1];
|
||||
alert(`HTTP Error ${status}: Cannot load file from ${url}\n\n${error.message}`);
|
||||
} else {
|
||||
alert(`Error loading file from ${url}:\n\n${error.message}`);
|
||||
}
|
||||
|
||||
throw error; // Re-throw so loadFiles can handle it
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(splatFiles) {
|
||||
if (guiOptions.resetOnLoad) {
|
||||
const toRemove = frame.children.filter((child) => child instanceof SplatMesh);
|
||||
for (const child of toRemove) {
|
||||
frame.remove(child);
|
||||
}
|
||||
// Clear original file bytes when resetting
|
||||
inputs.length = 0;
|
||||
splatsFolder.foldersRecursive().forEach((child) => child.destroy());
|
||||
}
|
||||
|
||||
guiOptions.openCv = true;
|
||||
guiOptions.autoRotate = false;
|
||||
resetFrameQuaternion();
|
||||
writeOptions.filename = "";
|
||||
|
||||
// Show progress bar if loading from URLs
|
||||
const hasUrls = splatFiles.some(file => typeof file === "string");
|
||||
if (hasUrls) {
|
||||
showProgress();
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for (const splatFile of splatFiles) {
|
||||
try {
|
||||
let fileBytes;
|
||||
let fileName;
|
||||
|
||||
// Check if splatFile is a URL string or a File object
|
||||
if (typeof splatFile === "string") {
|
||||
// It's a URL, fetch it
|
||||
console.log(`Fetching ${splatFile}...`);
|
||||
fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
|
||||
// Extract filename from URL
|
||||
fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";
|
||||
} else {
|
||||
// It's a File object
|
||||
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
|
||||
fileName = splatFile.name;
|
||||
}
|
||||
|
||||
if (writeOptions.filename === "") {
|
||||
writeOptions.filename = fileName.split(".")[0];
|
||||
}
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: fileBytes.slice(), fileName });
|
||||
const translate = guiOptions.loadOffset * index
|
||||
splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
|
||||
splatMesh.enableWorldToView = true;
|
||||
splatMesh.worldModifier = makeWorldModifier(splatMesh);
|
||||
await splatMesh.initialized;
|
||||
|
||||
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
|
||||
frame.add(splatMesh);
|
||||
console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
|
||||
|
||||
const splatFolder = splatsFolder.addFolder(fileName).close();
|
||||
splatFolder.add(splatMesh, "opacity", 0, 1, 0.01).name("Opacity").listen();
|
||||
splatFolder.add(splatMesh.position, "x", -10, 10, 0.01).name("X").listen();
|
||||
splatFolder.add(splatMesh.position, "y", -10, 10, 0.01).name("Y").listen();
|
||||
splatFolder.add(splatMesh.position, "z", -10, 10, 0.01).name("Z").listen();
|
||||
splatFolder.add(splatMesh.scale, "x", 0.01, 4, 0.01).name("Scale").listen().onChange((value) => {
|
||||
splatMesh.scale.setScalar(value);
|
||||
});
|
||||
splatFolder.add(splatMesh.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
|
||||
splatFolder.add(splatMesh.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
|
||||
splatFolder.add(splatMesh.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
|
||||
splatFolder.add(splatMesh, "maxSh", 0, 3, 1).name("Max SH").listen().onChange(() => {
|
||||
splatMesh.updateGenerator();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading splat file:", error);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Hide progress bar when done
|
||||
if (hasUrls) {
|
||||
hideProgress();
|
||||
}
|
||||
|
||||
// Restore focus to canvas for keyboard controls
|
||||
canvas.focus();
|
||||
}
|
||||
|
||||
secondGui.add(guiOptions, "resetOnLoad").name("Reset on load");
|
||||
secondGui.add(guiOptions, "loadOffset", -2, 2, 0.01).name("Loading offset");
|
||||
secondGui.add(guiOptions, "openFiles").name("Select Files");
|
||||
secondGui.add(guiOptions, "loadFromText").name("Paste URL(s) here");
|
||||
secondGui.add(guiOptions, "loadFromTextAction").name("Load from URL(s)");
|
||||
|
||||
cameraFolder.add(guiOptions, "resetPose").name("Reset pose");
|
||||
cameraFolder.add(guiOptions, "openCv").name("OpenCV coordinates")
|
||||
.listen()
|
||||
.onChange(resetFrameQuaternion);
|
||||
cameraFolder.add(guiOptions, "autoRotate").name("Auto rotate")
|
||||
.listen()
|
||||
.onChange((value) => {
|
||||
if (value) {
|
||||
frame.rotation.y = 0;
|
||||
}
|
||||
});
|
||||
cameraFolder.add(guiOptions, "orbit").name("Orbit controls")
|
||||
.listen()
|
||||
.onChange((value) => {
|
||||
orbitControls.enabled = value;
|
||||
canvas.focus();
|
||||
rotX.enable(!value);
|
||||
rotY.enable(!value);
|
||||
rotZ.enable(!value);
|
||||
});
|
||||
// .setValue(false);
|
||||
cameraFolder.add(guiOptions, "reversePointerDir").name("Reverse ptr direction")
|
||||
.onChange((value) => {
|
||||
controls.pointerControls.reverseRotate = value;
|
||||
controls.pointerControls.reverseScroll = value;
|
||||
});
|
||||
cameraFolder.add(guiOptions, "reversePointerSlide").name("Reverse ptr slide")
|
||||
.onChange((value) => {
|
||||
controls.pointerControls.reverseSlide = value;
|
||||
controls.pointerControls.reverseSwipe = value;
|
||||
});
|
||||
|
||||
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
|
||||
renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
renderer.setSize(width, height, false);
|
||||
console.log("Render size", canvas.width, canvas.height);
|
||||
});
|
||||
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
|
||||
|
||||
const normalColor = dyno.dynoBool(false);
|
||||
gui.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
|
||||
|
||||
const splatsFolder = secondGui.addFolder("Files");
|
||||
|
||||
const editFolder = gui.addFolder("Edit Splats").close();
|
||||
editFolder.add(forge, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
|
||||
editFolder.add(forge, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
|
||||
|
||||
function updateFrameSplats() {
|
||||
frame.children.forEach((child) => {
|
||||
if (child instanceof SplatMesh) {
|
||||
child.updateVersion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const clipEnable = dyno.dynoBool(false);
|
||||
const clipMinX = dyno.dynoFloat(-5);
|
||||
const clipMaxX = dyno.dynoFloat(5);
|
||||
const clipMinY = dyno.dynoFloat(-5);
|
||||
const clipMaxY = dyno.dynoFloat(5);
|
||||
const clipMinZ = dyno.dynoFloat(-5);
|
||||
const clipMaxZ = dyno.dynoFloat(5);
|
||||
editFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMinX, "value", -5, 5, 0.01).name("Min X").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMaxX, "value", -5, 5, 0.01).name("Max X").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMinY, "value", -5, 5, 0.01).name("Min Y").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMaxY, "value", -5, 5, 0.01).name("Max Y").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMinZ, "value", -5, 5, 0.01).name("Min Z").onChange(() => updateFrameSplats());
|
||||
editFolder.add(clipMaxZ, "value", -5, 5, 0.01).name("Max Z").onChange(() => updateFrameSplats());
|
||||
|
||||
function makeWorldModifier(mesh) {
|
||||
const context = mesh.context;
|
||||
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
|
||||
// Color by world normal if it's enabled
|
||||
let worldNormal = dyno.gsplatNormal(gsplat);
|
||||
let { rgb, center, opacity } = dyno.splitGsplat(gsplat).outputs;
|
||||
// Compute vector from view to object coordinate in gsplat
|
||||
const worldToView = context.worldToView;
|
||||
const viewGsplat = worldToView.applyGsplat(gsplat);
|
||||
const viewCenter = dyno.splitGsplat(viewGsplat).outputs.center;
|
||||
let normal = dyno.gsplatNormal(viewGsplat);
|
||||
const dot = dyno.dot(viewCenter, normal);
|
||||
const sameDir = dyno.greaterThanEqual(dot, dyno.dynoConst("float", 0));
|
||||
normal = dyno.select(sameDir, dyno.neg(worldNormal), worldNormal);
|
||||
const normalRgb = dyno.add(dyno.mul(normal, dyno.dynoConst("float", 0.5)), dyno.dynoConst("float", 0.5));
|
||||
rgb = dyno.select(normalColor, normalRgb, rgb);
|
||||
|
||||
// Zero out opacity if outside clip bounds
|
||||
const { x, y, z } = dyno.split(center).outputs;
|
||||
const xWithin = dyno.and(dyno.greaterThanEqual(x, clipMinX), dyno.lessThanEqual(x, clipMaxX));
|
||||
const yWithin = dyno.and(dyno.greaterThanEqual(y, clipMinY), dyno.lessThanEqual(y, clipMaxY));
|
||||
const zWithin = dyno.and(dyno.greaterThanEqual(z, clipMinZ), dyno.lessThanEqual(z, clipMaxZ));
|
||||
const within = dyno.and(dyno.and(xWithin, yWithin), zWithin);
|
||||
const splatEnabled = dyno.or(
|
||||
dyno.not(clipEnable),
|
||||
within,
|
||||
);
|
||||
opacity = dyno.select(splatEnabled, opacity, dyno.dynoConst("float", 0));
|
||||
|
||||
gsplat = dyno.combineGsplat({
|
||||
gsplat,
|
||||
rgb,
|
||||
opacity,
|
||||
})
|
||||
return { gsplat };
|
||||
});
|
||||
}
|
||||
|
||||
const writeFolder = secondGui.addFolder("Write Gsplats").close();
|
||||
const writeOptions = {
|
||||
filename: "gsplats",
|
||||
trimOpacity: true,
|
||||
trimOpacityThreshold: 0,
|
||||
maxSh: 3,
|
||||
fractionalBits: 12,
|
||||
writeSpz: async () => {
|
||||
const transcodeInputs = inputs.map((input) => {
|
||||
return {
|
||||
fileBytes: new Uint8Array(input.fileBytes),
|
||||
pathOrUrl: input.pathOrUrl,
|
||||
transform: {
|
||||
translate: input.object.position.toArray(),
|
||||
quaternion: input.object.quaternion.toArray(),
|
||||
scale: (input.object.scale.x + input.object.scale.y + input.object.scale.z) / 3,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const clipXyz = clipEnable.value ? {
|
||||
min: [clipMinX.value, clipMinY.value, clipMinZ.value],
|
||||
max: [clipMaxX.value, clipMaxY.value, clipMaxZ.value],
|
||||
} : undefined;
|
||||
const maxSh = writeOptions.maxSh;
|
||||
const fractionalBits = writeOptions.fractionalBits;
|
||||
const opacityThreshid = writeOptions.trimOpacity ? writeOptions.trimOpacityThreshold : undefined;
|
||||
debugger;
|
||||
const transcode = await transcodeSpz({ inputs: transcodeInputs, maxSh, clipXyz, fractionalBits, opacityThreshold: opacityThreshid });
|
||||
|
||||
if (transcode.clippedCount && transcode.clippedCount > 0) {
|
||||
console.log(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
|
||||
alert(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
|
||||
}
|
||||
|
||||
const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = writeOptions.filename + ".spz";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
writeFolder.add(writeOptions, "filename").name("Filename").listen();
|
||||
writeFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
|
||||
writeFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
|
||||
writeFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
|
||||
writeFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
|
||||
writeFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
|
||||
|
||||
function makeInstructions() {
|
||||
const instructions = textSplats({
|
||||
text: "Drag and Drop\na Gsplat file\nhere to view",
|
||||
textAlign: "center",
|
||||
fontSize: 64,
|
||||
objectScale: 0.1 / 64,
|
||||
});
|
||||
instructions.quaternion.set(1, 0, 0, 0);
|
||||
instructions.enableWorldToView = true;
|
||||
instructions.worldModifier = makeWorldModifier(instructions);
|
||||
instructions.updateGenerator();
|
||||
return instructions;
|
||||
}
|
||||
|
||||
const instructions = makeInstructions();
|
||||
frame.add(instructions);
|
||||
|
||||
// Load URLs from query parameters if any
|
||||
const urlsToLoad = parseURLsFromQuery();
|
||||
if (urlsToLoad.length > 0) {
|
||||
console.log(`Loading ${urlsToLoad.length} URLs from query parameters:`, urlsToLoad);
|
||||
loadFiles(urlsToLoad);
|
||||
}
|
||||
|
||||
// Focus the canvas initially for keyboard controls
|
||||
canvas.focus();
|
||||
|
||||
// Drag and drop functionality
|
||||
canvas.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
canvas.style.opacity = "0.5";
|
||||
});
|
||||
|
||||
canvas.addEventListener("dragleave", (e) => {
|
||||
e.preventDefault();
|
||||
canvas.style.opacity = "1";
|
||||
});
|
||||
|
||||
canvas.addEventListener("drop", async (e) => {
|
||||
e.preventDefault();
|
||||
canvas.style.opacity = "1";
|
||||
|
||||
// Check for text data first
|
||||
const textData = e.dataTransfer.getData('text/plain');
|
||||
if (textData) {
|
||||
const urls = parseURLsFromText(textData);
|
||||
if (urls.length > 0) {
|
||||
console.log(`Found ${urls.length} URLs in dropped text:`, urls);
|
||||
loadFiles(urls);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to file handling
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
console.log("Dropped files:", files.length, files);
|
||||
const splatFiles = files.filter((file) =>
|
||||
file.name.toLowerCase().endsWith('.ply') ||
|
||||
file.name.toLowerCase().endsWith('.spz') ||
|
||||
file.name.toLowerCase().endsWith('.splat') ||
|
||||
file.name.toLowerCase().endsWith('.ksplat')
|
||||
);
|
||||
|
||||
if (splatFiles.length > 0) {
|
||||
loadFiles(splatFiles);
|
||||
}
|
||||
});
|
||||
|
||||
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
|
||||
function parseURLsFromText(text) {
|
||||
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat"];
|
||||
const urls = [];
|
||||
|
||||
// Split by lines, commas, and semicolons
|
||||
const parts = text.trim().split(/[\r\n,;]+/);
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
|
||||
// Check if part looks like a URL
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
// Check if it has a supported extension
|
||||
const hasValidExtension = supportedExtensions.some(ext =>
|
||||
trimmed.toLowerCase().includes(ext)
|
||||
);
|
||||
|
||||
if (hasValidExtension) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
let lastTime = null;
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
const deltaTime = time - (lastTime || time);
|
||||
lastTime = time;
|
||||
|
||||
if (guiOptions.autoRotate) {
|
||||
frame.rotation.y += deltaTime / 5000;
|
||||
}
|
||||
|
||||
if (guiOptions.orbit) {
|
||||
orbitControls.update();
|
||||
} else {
|
||||
controls.update(camera);
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user