← Files Aholo Lux3DARCHIVED FILE
skills/lux3d/core/viewer/viewer.js
14 KB · Oct 5, 2026 · 18:34 UTC
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const data = JSON.parse(document.getElementById('lux3d-data').textContent);
const manifest = data.manifest;
const t = (key, values = {}) => data.messages[key].replace(/\{(\w+)\}/g, (_, name) => {
if (!Object.hasOwn(values, name)) throw new Error(`Missing translation parameter: ${key}.${name}`);
return String(values[name]);
});
const artifacts = new Map(manifest.artifacts.map(value => [value.id, value]));
const tasks = new Map(manifest.tasks.map(value => [value.id, value]));
const attempts = new Map(manifest.attempts.map(value => [value.id, value]));
const views = [];
const downloadUrls = new Set();
let renderer;
let environment;
let draco;
let ktx2;
let loader;
function element(tag, text, className) {
const result = document.createElement(tag);
if (text !== undefined) result.textContent = text;
if (className) result.className = className;
return result;
}
function bytes(base64) {
const decoded = atob(base64);
return Uint8Array.from(decoded, character => character.charCodeAt(0));
}
function field(list, label, value) {
const wrapper = element('div');
const unavailable = value === null || value === undefined;
wrapper.append(element('dt', label), element('dd', unavailable ? t('unavailable') : String(value), unavailable ? 'unavailable' : undefined));
list.append(wrapper);
}
function localDuration(attempt) {
return attempt?.localDurationMs == null ? null : t('durationSeconds', { seconds: attempt.localDurationMs / 1000 });
}
function downloadLink(artifact) {
const label = t('download', { format: artifact.format.toUpperCase() });
const link = element('a', label);
link.setAttribute('aria-label', label);
link.href = '#';
link.download = artifact.path.split('/').pop();
link.addEventListener('click', event => {
if (!data.assets[artifact.id]) { event.preventDefault(); return; }
if (link.href.startsWith('blob:')) return;
const url = URL.createObjectURL(new Blob([bytes(data.assets[artifact.id])], { type: 'application/octet-stream' }));
downloadUrls.add(url);
link.href = url;
});
return link;
}
function initializeRenderer() {
if (renderer) return;
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('viewer-canvas'), antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const pmrem = new THREE.PMREMGenerator(renderer);
const room = new RoomEnvironment();
environment = pmrem.fromScene(room);
room.dispose(); pmrem.dispose();
const manager = new THREE.LoadingManager();
manager.setURLModifier(url => {
if (Object.hasOwn(data.decoders, url)) return data.decoders[url];
if (url.startsWith('data:') || url.startsWith('blob:')) return url;
throw new Error('RESOURCE_NOT_EMBEDDED');
});
draco = new DRACOLoader(manager).setDecoderPath({
js: 'lux3d-embedded/draco/draco_wasm_wrapper.js', wasm: 'lux3d-embedded/draco/draco_decoder.wasm',
});
ktx2 = new KTX2Loader(manager).setTranscoderPath('lux3d-embedded/basis/').detectSupport(renderer);
loader = new GLTFLoader(manager).setDRACOLoader(draco).setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
renderer.setAnimationLoop(render);
}
function fit(view) {
const rect = view.viewport.getBoundingClientRect();
const vertical = THREE.MathUtils.degToRad(view.camera.fov) / 2;
const horizontal = Math.atan(Math.tan(vertical) * rect.width / rect.height);
// A bounding sphere fits exactly at r/sin(half-FOV). The extra 5% is display padding.
const distance = view.sphere.radius / Math.sin(Math.min(vertical, horizontal)) * 1.05;
view.camera.position.copy(view.sphere.center).addScaledVector(new THREE.Vector3(1, 0.65, 1).normalize(), distance);
view.controls.minDistance = view.sphere.radius * 1.05;
view.controls.maxDistance = distance * 4;
// Clip bounds cover the whole object throughout the allowed orbit/zoom range.
view.camera.near = (view.controls.minDistance - view.sphere.radius) / 2;
view.camera.far = view.controls.maxDistance + view.sphere.radius * 2;
view.controls.target.copy(view.sphere.center);
view.controls.update();
}
async function addPreview(viewport, artifact) {
const notice = element('div', t('loading'), 'preview-notice');
viewport.append(notice);
viewport.dataset.previewState = 'loading';
try {
initializeRenderer();
const gltf = await loader.parseAsync(bytes(data.assets[artifact.id]).buffer, '');
const sphere = new THREE.Box3().setFromObject(gltf.scene, true).getBoundingSphere(new THREE.Sphere());
if (!Number.isFinite(sphere.radius) || sphere.radius <= 0) throw new Error('NO_RENDERABLE_GEOMETRY');
const scene = new THREE.Scene();
// Transparent WebGL preserves the neutral CSS studio background. Asset materials are unchanged.
scene.environment = environment.texture;
scene.add(gltf.scene);
const camera = new THREE.PerspectiveCamera(38);
const controls = new OrbitControls(camera, viewport);
controls.addEventListener('change', () => { viewport.dataset.interactions = String(Number(viewport.dataset.interactions || 0) + 1); });
viewport.addEventListener('wheel', event => event.preventDefault(), { passive: false });
const reset = element('button', t('resetView'), 'reset');
reset.type = 'button';
const view = { viewport, scene, camera, controls, sphere, notice, gltf };
reset.addEventListener('click', () => fit(view));
const tools = element('div', undefined, 'stage-tools');
tools.append(element('span', t('orbitHint'), 'stage-hint'), reset);
viewport.append(element('span', t('localPreview'), 'stage-label'), tools);
fit(view);
views.push(view);
} catch {
viewport.dataset.previewState = 'error';
notice.textContent = t('previewError');
}
}
function render() {
const ratio = renderer.getPixelRatio();
if (renderer.domElement.width !== Math.floor(innerWidth * ratio) || renderer.domElement.height !== Math.floor(innerHeight * ratio)) {
renderer.setSize(innerWidth, innerHeight);
}
renderer.domElement.style.transform = `translateY(${window.scrollY}px)`;
renderer.setScissorTest(false);
renderer.setClearColor(0, 0);
renderer.clear();
renderer.setScissorTest(true);
for (const view of views) {
const rect = view.viewport.getBoundingClientRect();
if (rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth) continue;
view.camera.aspect = rect.width / rect.height;
view.camera.updateProjectionMatrix();
const left = Math.max(0, rect.left), bottom = Math.max(0, innerHeight - rect.bottom);
renderer.setScissor(left, bottom, Math.min(rect.right, innerWidth) - left, Math.min(innerHeight - rect.top, innerHeight) - bottom);
renderer.setViewport(rect.left, innerHeight - rect.bottom, rect.width, rect.height);
renderer.render(view.scene, view.camera);
view.viewport.dataset.previewState = 'ready';
view.notice.hidden = true;
}
}
function card(item, index) {
const result = element('article', undefined, 'card');
result.id = `item-${item.id}`;
const heading = element('div', undefined, 'card-heading');
const label = element('div', undefined, 'asset-heading');
label.append(element('span', String(index + 1).padStart(2, '0'), 'asset-index'), element('h2', item.label));
heading.append(label, element('span', t(item.status === 'complete' ? 'assetsReady' : 'incomplete'), `badge ${item.status}`));
const viewport = element('div', undefined, 'viewport');
viewport.tabIndex = 0; viewport.setAttribute('aria-label', t('itemPreview', { label: item.label }));
const selected = attempts.get(item.selectedAttemptId);
const task = tasks.get(selected?.taskRef);
const metadata = element('dl', undefined, 'metadata');
field(metadata, t('taskId'), task?.taskId);
field(metadata, t('taskCreated'), task?.createdAt);
field(metadata, t('localStart'), selected?.localStartedAt);
field(metadata, t('localDuration'), localDuration(selected));
const downloads = element('div', undefined, 'downloads');
for (const id of item.artifactIds) downloads.append(downloadLink(artifacts.get(id)));
const info = element('div', undefined, 'asset-info');
info.append(metadata, downloads);
const body = element('div', undefined, 'card-body');
body.append(viewport, info);
result.append(heading, body);
const preview = item.artifactIds.map(id => artifacts.get(id)).find(artifact => artifact.format === 'glb');
if (preview) void addPreview(viewport, preview);
else { viewport.dataset.previewState = 'unavailable'; viewport.append(element('div', t('noGlb'), 'preview-notice')); }
return result;
}
function sceneCard() {
const scene = manifest.scene;
if (scene.status !== 'ready') {
const message = element('div', undefined, 'scene-message');
const symbol = element('span', '◇', 'scene-symbol');
symbol.setAttribute('aria-hidden', 'true');
const copy = element('div');
copy.append(element('h2', t('assembledScene')), element('div', scene.status === 'not-requested' ? t('sceneNotRequested') : t('sceneIncomplete', {
reason: scene.failureCode === 'scene-validation-failed' ? t('sceneValidationFailed') : scene.reason,
}), 'scene-reason'));
message.append(symbol, copy);
return message;
}
const result = element('article', undefined, 'card scene-card');
const heading = element('div', undefined, 'card-heading');
heading.append(element('h2', t('assembledScene')), element('span', t('validatedExport'), 'badge'));
const viewport = element('div', undefined, 'viewport');
viewport.tabIndex = 0;
viewport.setAttribute('aria-label', t('scenePreview'));
const metadata = element('dl', undefined, 'metadata');
field(metadata, t('executionEvidence'), t(scene.execution.kind === 'development-fixture' ? 'developmentFixture' : 'suppliedRecord'));
field(metadata, t('tool'), scene.execution.toolName);
field(metadata, t('executionTime'), scene.execution.executedAt);
field(metadata, t('toolCallId'), scene.execution.callId);
const sourceList = element('ul');
for (const source of scene.sources) {
const item = manifest.items.find(item => item.id === source.itemId);
const attempt = attempts.get(source.attemptId);
const task = tasks.get(attempt.taskRef);
const entry = element('li');
const link = element('a', t('sceneSource', { label: item.label, attempt: source.attemptId, task: task?.taskId ?? t('unavailable') }));
link.href = `#item-${source.itemId}`;
entry.append(link); sourceList.append(entry);
}
const downloads = element('div', undefined, 'downloads');
downloads.append(downloadLink(artifacts.get(scene.artifactId)), downloadLink(artifacts.get(scene.evidenceArtifactId)));
result.append(heading, viewport, metadata, sourceList, downloads);
void addPreview(viewport, artifacts.get(scene.artifactId));
return result;
}
for (const [value, label, note] of [
[manifest.items.length, t('individualItems'), t('inCollection')],
[manifest.tasks.length, t('uniqueTasks'), t('countedOnce')],
]) {
const metric = element('div', undefined, 'metric');
metric.append(element('span', String(value), 'metric-value'), element('span', label, 'metric-label'), element('span', note, 'metric-note'));
document.getElementById('summary').append(metric);
}
const status = document.getElementById('delivery-status');
status.classList.toggle('complete', manifest.status === 'complete');
status.append(element('strong', t(manifest.status === 'complete' ? 'deliveryComplete' : 'unverifiedSteps')),
element('span', t(manifest.status === 'complete' ? 'completeNote' : 'incompleteNote')));
const grid = document.getElementById('items');
grid.dataset.count = String(manifest.items.length);
document.getElementById('asset-count').textContent = manifest.items.length;
document.getElementById('attempt-count').textContent = manifest.attempts.length;
manifest.items.forEach((item, index) => grid.append(card(item, index)));
const manifestLink = document.getElementById('manifest-download');
const manifestUrl = URL.createObjectURL(new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' }));
manifestLink.href = manifestUrl;
downloadUrls.add(manifestUrl);
document.getElementById('scene-section').append(sceneCard());
const notices = element('details');
notices.append(element('summary', t('thirdPartyNotices')), element('pre', data.notices));
document.querySelector('footer').append(notices);
for (const attempt of manifest.attempts) {
const task = tasks.get(attempt.taskRef);
const row = element('tr');
for (const value of [attempt.id, task ? `${task.taskId} / ${task.region}` : t('unavailable'), t(`state.${attempt.status}`), attempt.localStartedAt ?? t('unavailable'), attempt.localCompletedAt ?? t('unavailable'), localDuration(attempt) ?? t('unavailable')]) row.append(element('td', value));
const originals = element('td');
for (const id of attempt.artifactIds) originals.append(downloadLink(artifacts.get(id)));
if (!attempt.artifactIds.length) originals.textContent = t('noOriginals');
row.append(originals);
document.getElementById('history').append(row);
}
window.addEventListener('pagehide', () => {
renderer?.setAnimationLoop(null);
for (const view of views) {
view.controls.dispose();
view.scene.traverse(object => {
object.geometry?.dispose();
const materials = Array.isArray(object.material) ? object.material : object.material ? [object.material] : [];
for (const material of materials) {
for (const value of Object.values(material)) if (value?.isTexture) { value.source?.data?.close?.(); value.dispose(); }
material.dispose();
}
});
}
environment?.dispose(); draco?.dispose(); ktx2?.dispose(); renderer?.dispose();
for (const url of downloadUrls) URL.revokeObjectURL(url);
});
SHA-256: a4e5635c26180309ece59358e3c5c4547eeff0479eab432725b77ec1858f0e6c