Files
spark/examples/editor/index.html
T
Andreas Sundquist 179885595c Splat Texture (#79)
* Add experimental Splat Texture rendering and examples/splat-texture. Added star.png and heart.png to assets.json and to assets repo. Update examples/depth-of-field to compute from metric aperture size. Added SparkRenderer.renderScale debug parameter. Updated examples/editor: set default highDPI to true, added logFocalDistance, apertureAngle, refactored, grouped into debugFolder, default settings open, preset buttons for nonAA and AA settings.

* Set default highDpi in examples/editor to not(isMobile).
2025-07-01 07:57:34 -07:00

746 lines
26 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 */
touch-action: none;
}
#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, isMobile, isPcSogs } 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")
});
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: !isMobile(),
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, .ksplat, or .json");
}
}
},
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;
let url = null;
// 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";
if (isPcSogs(fileBytes)) {
url = splatFile;
}
} else {
// It's a File object
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
fileName = splatFile.name;
}
if (writeOptions.filename === "") {
writeOptions.filename = fileName.split(".")[0];
}
const init = url ? { url } : { fileBytes: fileBytes.slice(), fileName };
const splatMesh = new SplatMesh(init);
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;
if (!url) {
// PC SOGS transcode not supported yet
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;
});
function setHighDpi(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);
}
setHighDpi(guiOptions.highDevicePixel);
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
setHighDpi(value);
});
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();
gui.add({
logFocalDistance: 0.0,
}, "logFocalDistance", -2, 2, 0.01).name("Ln(Focal distance)").onChange((value) => {
spark.focalDistance = Math.exp(value);
});
gui.add(spark, "apertureAngle", 0, 0.01 * Math.PI, 0.001).name("Aperture angle").listen();
const debugFolder = gui.addFolder("Debug").close();
const normalColor = dyno.dynoBool(false);
debugFolder.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
debugFolder.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
debugFolder.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
debugFolder.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)").listen();
debugFolder.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)").listen();
debugFolder.add({
nonAA: () => {
spark.preBlurAmount = 0.3;
spark.blurAmount = 0.0;
},
}, "nonAA").name("Non-AA preset");
debugFolder.add({
AA: () => {
spark.preBlurAmount = 0.0;
spark.blurAmount = 0.3;
},
}, "AA").name("AA preset");
debugFolder.add(spark, "renderScale", 0.1, 2.0, 0.1).name("Render scale");
const splatsFolder = secondGui.addFolder("Files");
const clipFolder = gui.addFolder("Clip 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);
clipFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
clipFolder.add(clipMinX, "value", -50, 50, 0.01).name("Min X").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxX, "value", -50, 50, 0.01).name("Max X").onChange(() => updateFrameSplats());
clipFolder.add(clipMinY, "value", -50, 50, 0.01).name("Min Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxY, "value", -50, 50, 0.01).name("Max Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMinZ, "value", -50, 50, 0.01).name("Min Z").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxZ, "value", -50, 50, 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 exportFolder = secondGui.addFolder("Export 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);
},
};
exportFolder.add(writeOptions, "filename").name("Filename").listen();
exportFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
exportFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
exportFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
exportFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
exportFolder.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", ".json"];
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>