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:
Amp
2026-08-24 10:51:26 +00:00
co-authored by heaust
parent df8b69e09d
commit 951449db0b
99 changed files with 4004 additions and 376 deletions
+3 -1
View File
@@ -35,7 +35,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
return nil, err
}
pages := make(map[string][]byte)
for _, name := range []string{"home.html", "docs.html", "playground.html"} {
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
content, err := webFiles.ReadFile("web/" + name)
if err != nil {
return nil, err
@@ -63,6 +63,8 @@ func (app *application) routes() http.Handler {
mux.HandleFunc("/docs/", app.servePage("docs.html"))
mux.HandleFunc("/playground", app.servePlayground)
mux.HandleFunc("/playground/", app.servePlayground)
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
mux.HandleFunc("/", app.serveHome)
return app.headers(mux)
}
+21
View File
@@ -144,3 +144,24 @@ func TestSavedPlaygroundPageRoute(t *testing.T) {
t.Fatalf("content type = %q", contentType)
}
}
func TestGltfViewerPageRoute(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil)
response := httptest.NewRecorder()
testApplication(t).routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" {
t.Fatalf("content type = %q", contentType)
}
for _, content := range []string{
`"@yawn/core": "/pkg/core.js"`,
`"@yawn/handles": "/pkg/handles.js"`,
"Drop a glTF file",
} {
if !bytes.Contains(response.Body.Bytes(), []byte(content)) {
t.Fatalf("viewer page does not contain %q", content)
}
}
}
+185
View File
@@ -0,0 +1,185 @@
:root {
color: #d7e9f8;
background: #06111d;
font-family:
Inter,
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
.viewer {
width: 100%;
height: 100%;
height: 100dvh;
margin: 0;
overflow: hidden;
}
.viewer {
position: relative;
background: #06111d;
}
.viewer::after {
position: absolute;
inset: 0;
border: 2px solid transparent;
content: "";
pointer-events: none;
transition:
border-color 140ms ease,
background 140ms ease;
}
.viewer.is-dragging::after {
border-color: #35a9ed;
background: rgb(20 112 168 / 9%);
}
canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
touch-action: none;
}
.render-time {
position: absolute;
z-index: 2;
top: 18px;
left: 18px;
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 11px;
color: #718da2;
border: 1px solid #23435a;
border-radius: 7px;
background: rgb(8 25 39 / 82%);
box-shadow: 0 8px 24px rgb(0 8 15 / 18%);
font:
700 9px/1 ui-monospace,
SFMono-Regular,
Menlo,
monospace;
letter-spacing: 0.08em;
pointer-events: none;
text-transform: uppercase;
backdrop-filter: blur(10px);
}
.render-time[hidden] {
display: none;
}
.render-time strong {
color: #8bd5fb;
font-size: 11px;
letter-spacing: 0;
text-transform: none;
}
.drop-prompt {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
display: grid;
min-width: min(360px, calc(100% - 36px));
justify-items: center;
gap: 8px;
padding: 28px 32px;
transform: translate(-50%, -50%);
color: inherit;
border: 1px solid transparent;
border-radius: 14px;
outline: none;
background: transparent;
cursor: pointer;
text-align: center;
}
.drop-prompt:hover,
.drop-prompt:focus-visible,
.viewer.is-dragging .drop-prompt {
border-color: #25577a;
background: rgb(11 41 65 / 72%);
box-shadow: 0 18px 50px rgb(0 10 18 / 22%);
}
.drop-prompt[hidden] {
display: none;
}
.viewer.is-dragging .drop-prompt {
display: grid;
}
.drop-icon {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 7px;
place-items: center;
color: #54bdf6;
border: 1px solid #25577a;
border-radius: 13px;
background: #0b2941;
box-shadow: 0 0 34px rgb(46 171 238 / 15%);
font:
700 19px/1 ui-monospace,
monospace;
}
.drop-prompt strong {
color: #c5d9e8;
font-size: 14px;
letter-spacing: -0.01em;
}
.drop-prompt small {
color: #698297;
font-size: 10px;
}
.drop-prompt.is-error .drop-icon {
color: #f0a5a5;
border-color: #6f3540;
background: #351b27;
box-shadow: 0 0 34px rgb(205 78 91 / 12%);
}
.drop-prompt.is-error strong {
color: #e8b5b5;
}
@media (max-width: 560px) {
.render-time {
top: 12px;
left: 12px;
}
.drop-prompt {
padding: 24px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
}
}
+309
View File
@@ -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();
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#06111d" />
<meta
name="description"
content="Open a local glTF or GLB file in the Yawn WebGPU viewer."
/>
<title>glTF Viewer — Yawn</title>
<link rel="stylesheet" href="/assets/gltf.css" />
<script type="importmap">
{
"imports": {
"@yawn/core": "/pkg/core.js",
"@yawn/handles": "/pkg/handles.js"
}
}
</script>
<script type="module" src="/assets/gltf.js"></script>
</head>
<body>
<main id="drop-zone" class="viewer" aria-label="Yawn glTF viewer">
<canvas id="canvas" aria-label="3D model preview"></canvas>
<output id="render-time" class="render-time" hidden>
<span>Render</span>
<strong id="render-value">— ms</strong>
</output>
<button id="drop-prompt" class="drop-prompt" type="button">
<span class="drop-icon" aria-hidden="true">↓</span>
<strong id="prompt-message">Drop a glTF file</strong>
<small id="prompt-detail"
>GLB or self-contained glTF · processed locally</small
>
</button>
<input
id="file-picker"
type="file"
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
hidden
/>
</main>
</body>
</html>