Files
yawn/server/web/assets/gltf.js
T

310 lines
9.0 KiB
JavaScript

import {
AmbientLight,
ArcRotateCamera,
Scene,
importGltf,
} from "@yawn/handles";
const elements = {
canvas: document.getElementById("canvas"),
detail: document.getElementById("prompt-detail"),
dropZone: document.getElementById("drop-zone"),
filePicker: document.getElementById("file-picker"),
message: document.getElementById("prompt-message"),
prompt: document.getElementById("drop-prompt"),
renderTime: document.getElementById("render-time"),
renderValue: document.getElementById("render-value"),
};
let activeViewer;
let loading = false;
function setPrompt(message, detail, error = false) {
elements.message.textContent = message;
elements.detail.textContent = detail;
elements.prompt.classList.toggle("is-error", error);
elements.prompt.hidden = false;
}
function resetPrompt() {
setPrompt(
"Drop a glTF file",
"GLB or self-contained glTF · processed locally",
);
}
function replaceCanvas() {
const canvas = elements.canvas.cloneNode();
const scale = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(elements.dropZone.clientWidth * scale));
canvas.height = Math.max(
1,
Math.round(elements.dropZone.clientHeight * scale),
);
elements.canvas.replaceWith(canvas);
elements.canvas = canvas;
return canvas;
}
function disposeViewer() {
if (!activeViewer) return;
cancelAnimationFrame(activeViewer.dirtyFrame);
activeViewer.stopProfile?.();
activeViewer.camera?.detachControls();
activeViewer.scene.dispose();
activeViewer = undefined;
}
function markDirtyEveryFrame(viewer) {
viewer.signals[5] = 1;
viewer.dirtyFrame = requestAnimationFrame(() => markDirtyEveryFrame(viewer));
}
function rotate([rx, ry, rz, rw], [x, y, z]) {
const tx = 2 * (ry * z - rz * y);
const ty = 2 * (rz * x - rx * z);
const tz = 2 * (rx * y - ry * x);
return [
x + rw * tx + ry * tz - rz * ty,
y + rw * ty + rz * tx - rx * tz,
z + rw * tz + rx * ty - ry * tx,
];
}
function findBounds(scene, meshes) {
const minimum = [Infinity, Infinity, Infinity];
const maximum = [-Infinity, -Infinity, -Infinity];
for (const mesh of meshes) {
const bounds = scene.array("bounds").row(mesh.id);
const position = Array.from(mesh.position);
const rotor = Array.from(mesh.rotor);
const scale = Array.from(mesh.scale);
for (const x of [bounds[0], bounds[4]])
for (const y of [bounds[1], bounds[5]])
for (const z of [bounds[2], bounds[6]]) {
const corner = rotate(rotor, [
x * scale[0],
y * scale[1],
z * scale[2],
]).map((value, lane) => value + position[lane]);
for (let lane = 0; lane < 3; lane++) {
minimum[lane] = Math.min(minimum[lane], corner[lane]);
maximum[lane] = Math.max(maximum[lane], corner[lane]);
}
}
}
if (![...minimum, ...maximum].every(Number.isFinite))
throw new Error("The file does not contain renderable mesh bounds.");
return {
center: minimum.map((value, lane) => (value + maximum[lane]) / 2),
extents: maximum.map((value, lane) => value - minimum[lane]),
radius: Math.max(
0.01,
Math.hypot(...maximum.map((value, lane) => value - minimum[lane])) / 2,
),
};
}
function cameraOptions(scene, meshes, canvas, bounds) {
const aspect = canvas.width / canvas.height;
const verticalFov = meshes.camera?.fov ?? 0.8;
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
let alpha = 5.0861;
let beta = 1.5454;
if (meshes.camera?.direction) {
const [x, y, z] = meshes.camera.direction;
alpha = Math.atan2(x, z);
beta = Math.acos(Math.max(-1, Math.min(1, y)));
} else if (bounds.extents[2] < bounds.extents[0] * 0.75) {
alpha = 0;
beta = 1.45;
} else if (bounds.extents[0] < bounds.extents[2] * 0.75) {
alpha = Math.PI / 2;
beta = 1.45;
}
const cameraDirection = [
Math.sin(beta) * Math.sin(alpha),
Math.cos(beta),
Math.sin(beta) * Math.cos(alpha),
];
const right = [Math.cos(alpha), 0, -Math.sin(alpha)];
const up = [
-Math.cos(beta) * Math.sin(alpha),
Math.sin(beta),
-Math.cos(beta) * Math.cos(alpha),
];
const tangentX = Math.tan(horizontalFov / 2);
const tangentY = Math.tan(verticalFov / 2);
let distance = 0;
for (const mesh of meshes) {
const positions = scene.geometryData(mesh.geometryId, "positions");
const position = Array.from(mesh.position);
const rotor = Array.from(mesh.rotor);
const scale = Array.from(mesh.scale);
for (let index = 0; index < positions.length; index += 3) {
const point = rotate(rotor, [
positions[index] * scale[0],
positions[index + 1] * scale[1],
positions[index + 2] * scale[2],
]).map((value, lane) => value + position[lane] - bounds.center[lane]);
const towardCamera = point.reduce(
(value, lane, index) => value + lane * cameraDirection[index],
0,
);
const horizontal = Math.abs(
point.reduce((value, lane, index) => value + lane * right[index], 0),
);
const vertical = Math.abs(
point.reduce((value, lane, index) => value + lane * up[index], 0),
);
distance = Math.max(
distance,
towardCamera + horizontal / tangentX,
towardCamera + vertical / tangentY,
);
}
}
distance = Math.max(0.01, distance * 1.08);
return {
targetPosition: bounds.center,
alpha,
beta,
radius: distance,
fov: verticalFov,
near: Math.max(0.001, (distance - bounds.radius) * 0.25),
far: Math.max(1000, distance + bounds.radius * 10),
aspect,
controls: { element: canvas, pointer: true },
};
}
function isGltf(file) {
return /\.(?:glb|gltf)$/iu.test(file.name);
}
async function openFile(file) {
if (loading) return;
if (!file || !isGltf(file)) {
setPrompt("That file is not glTF", "Drop a .glb or .gltf file.", true);
return;
}
if (!crossOriginIsolated) {
setPrompt(
"Cross-origin isolation is unavailable",
"This viewer needs COOP and COEP response headers.",
true,
);
return;
}
if (!navigator.gpu) {
setPrompt(
"WebGPU is unavailable",
"Open this page in a browser with WebGPU support.",
true,
);
return;
}
loading = true;
disposeViewer();
elements.renderTime.hidden = true;
setPrompt("Opening model…", `${file.name} stays on this device.`);
const url = URL.createObjectURL(file);
let scene;
try {
const canvas = replaceCanvas();
scene = new Scene(canvas, {
arenaBytes: 384 * 1024 * 1024,
hdr: true,
});
await scene.ready;
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
const meshes = await importGltf(scene, url);
if (!meshes.length)
throw new Error("The file does not contain any triangle meshes.");
const bounds = findBounds(scene, meshes);
const light = new AmbientLight(scene, {
color: [1, 0.94, 0.86],
intensity: 0.28,
});
const camera = new ArcRotateCamera(
scene,
cameraOptions(scene, meshes, canvas, bounds),
);
await Promise.all([light.ready, camera.ready]);
elements.renderValue.textContent = "— ms";
elements.renderTime.hidden = false;
const stopProfile = scene.core.onProfile((profile) => {
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
});
const profiling = await scene.core.setProfiler(true);
if (!profiling)
elements.renderTime.title =
"GPU timestamp queries are not supported by this adapter.";
else {
elements.renderTime.removeAttribute("title");
scene.array("sceneAccent").row(0)[3] = 1;
}
activeViewer = {
camera,
scene,
stopProfile,
signals: scene.core.array("signals").row(0),
};
markDirtyEveryFrame(activeViewer);
resetPrompt();
elements.prompt.hidden = true;
} catch (error) {
scene?.dispose();
const message = error instanceof Error ? error.message : String(error);
setPrompt("Could not open this model", message, true);
} finally {
URL.revokeObjectURL(url);
loading = false;
}
}
elements.prompt.addEventListener("click", () => {
if (!loading) elements.filePicker.click();
});
elements.filePicker.addEventListener("change", () => {
const [file] = elements.filePicker.files;
void openFile(file);
elements.filePicker.value = "";
});
for (const type of ["dragenter", "dragover"]) {
elements.dropZone.addEventListener(type, (event) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
elements.dropZone.classList.add("is-dragging");
});
}
elements.dropZone.addEventListener("dragleave", (event) => {
if (!elements.dropZone.contains(event.relatedTarget))
elements.dropZone.classList.remove("is-dragging");
});
elements.dropZone.addEventListener("drop", (event) => {
event.preventDefault();
elements.dropZone.classList.remove("is-dragging");
const [file] = event.dataTransfer?.files ?? [];
void openFile(file);
});
addEventListener("beforeunload", disposeViewer);
resetPrompt();