Add local glTF viewer and visual coverage
Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user