mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
711 lines
25 KiB
HTML
711 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Spark • Editor</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 src="/examples/js/vendor/stats.js/build/stats.min.js"></script>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "/examples/js/vendor/three/build/three.module.js",
|
|
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
|
"@sparkjsdev/spark": "/dist/spark.module.js",
|
|
"lil-gui": "/examples/js/vendor/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, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz } from "@sparkjsdev/spark";
|
|
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.01, 1000);
|
|
camera.position.set(0, 0, 1);
|
|
|
|
const canvas = document.getElementById("canvas");
|
|
const renderer = new THREE.WebGLRenderer({ canvas });
|
|
const spark = new SparkRenderer({ 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 stats = new Stats();
|
|
document.body.appendChild(stats.dom);
|
|
stats.dom.style.display = "none";
|
|
|
|
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 SparkControls({ 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,
|
|
stats: 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(guiOptions, "stats").name("Show frame stats").onChange((value) => {
|
|
stats.dom.style.display = value ? "block" : "none";
|
|
});
|
|
gui.add(spark.defaultView, "sortRadial").name("Radial sort").listen();
|
|
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());
|
|
|
|
gui.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
|
|
gui.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
|
|
gui.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)");
|
|
gui.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)");
|
|
|
|
const splatsFolder = secondGui.addFolder("Files");
|
|
|
|
const editFolder = gui.addFolder("Edit Splats").close();
|
|
|
|
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;
|
|
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;
|
|
stats.begin();
|
|
|
|
if (guiOptions.autoRotate) {
|
|
frame.rotation.y += deltaTime / 5000;
|
|
}
|
|
|
|
if (guiOptions.orbit) {
|
|
orbitControls.update();
|
|
} else {
|
|
controls.update(camera);
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
stats.end();
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|