import { createViewer } from './viewer.js';
import { initDropzone } from './dropzone.js';
import { HUD } from './hud.js';
import { KeyboardInput, TouchInput, CompositeInput } from './controls.js';
const DEMO_URL = 'assets/demo/scene.sog';
const PREFERS_REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)');
const errorBox = document.getElementById('viewer-error');
const spinner = document.getElementById('viewer-spinner');
const stage = document.getElementById('stage');
const viewer = createViewer(document.getElementById('canvas-host'));
const hud = new HUD(stage);
const btnOrbit = document.getElementById('btn-orbit');
const btnReset = document.getElementById('btn-reset');
function showError(msg) {
errorBox.textContent = msg;
errorBox.hidden = false;
setTimeout(() => { errorBox.hidden = true; }, 5000);
}
async function load(source, filename, opts) {
if (viewer.unsupported) return;
errorBox.hidden = true;
spinner.hidden = false;
try {
await viewer.loadSplat(source, filename, opts);
// Honour the OS-level reduced-motion preference: each fresh load
// resets autoOrbit to true inside the viewer; turn it back off
// for users who don't want spontaneous motion.
if (PREFERS_REDUCED_MOTION.matches) viewer.setAutoOrbit(false);
} catch {
showError('Could not load the splat.');
} finally {
spinner.hidden = true;
}
}
function syncOrbitButton() {
const on = viewer.isAutoOrbit();
btnOrbit.textContent = on ? '⏸ Auto-orbit' : '▶ Auto-orbit';
btnOrbit.setAttribute('aria-pressed', String(on));
btnOrbit.setAttribute('aria-label', on ? 'Pause auto-orbit' : 'Start auto-orbit');
}
btnOrbit.addEventListener('click', () => {
viewer.setAutoOrbit(!viewer.isAutoOrbit());
syncOrbitButton();
});
setInterval(syncOrbitButton, 500);
btnReset.addEventListener('click', async () => {
await load(DEMO_URL, 'scene.sog');
syncOrbitButton();
});
document.getElementById('btn-fullscreen').addEventListener('click', () => {
const enter = stage.requestFullscreen || stage.webkitRequestFullscreen;
const exit = document.exitFullscreen || document.webkitExitFullscreen;
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
// iOS Safari on iPhone exposes no Fullscreen API for non-<video> elements.
if (!enter) {
document.body.classList.toggle('fs-fallback');
window.dispatchEvent(new Event('resize'));
return;
}
if (fsEl) {
exit.call(document);
} else {
const result = enter.call(stage);
if (result && result.catch) result.catch(() => {
document.body.classList.add('fs-fallback');
window.dispatchEvent(new Event('resize'));
});
}
});
initDropzone({
stage: document.getElementById('stage'),
hint: document.getElementById('drop-hint'),
fileInput: document.getElementById('file-input'),
openButton: document.getElementById('btn-load'),
onFile: (file, badName) => {
if (file) load(file);
else showError(`Unsupported: ${badName} — only .ply is allowed`);
},
onSidecar: async (text) => {
if (!viewer.isCollisionEditor?.()) {
try { await viewer.enterCollisionEditor?.(); }
catch { showError('Load a splat before dropping a sidecar.'); return; }
}
const mode = viewer.getCollisionMode?.();
try { mode?.loadSidecar(text); }
catch (err) {
console.error('[collision] sidecar load failed:', err);
showError('Sidecar load failed — file format may be wrong.');
}
},
});
// ---------- Walking-mode wiring (slices 4–6) ----------
const keyboard = new KeyboardInput();
const touch = new TouchInput();
const input = new CompositeInput(keyboard, touch);
// Touch is attached/detached separately from keyboard because it binds
// to DOM elements that only matter inside walking-mode.
function attachInputs() {
keyboard.attach(window);
touch.attach({
root: document.getElementById('canvas-host'),
jumpBtn: document.getElementById('walk-btn-jump'),
flyBtn: document.getElementById('walk-btn-fly'),
exitBtn: document.getElementById('walk-exit'),
});
}
function detachInputs() {
keyboard.detach();
touch.detach();
}
viewer.onWalkingEnter?.(() => {
attachInputs();
hud.enterWalkingUI({ onExit: () => viewer.exitWalking?.(input) });
});
viewer.onWalkingExit?.(() => {
detachInputs();
hud.exitWalkingUI();
syncOrbitButton();
});
viewer.onWalkingModeChange?.((mode) => {
hud.setMode(mode);
hud.showHint();
});
viewer.onWalkingEyeChange?.((value) => {
hud.setEyeHeight(value);
});
viewer.onLoad?.(() => {
hud.showCTA(async () => {
try {
await viewer.enterWalking?.(input);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[walking] enter failed:', err);
const msg = err?.message === 'heightmap-build-failed'
? 'Splat has no usable geometry for walking-mode.'
: 'Could not enter walking mode.';
showError(msg);
}
});
});
// Re-acquire pointer-lock if it's lost while still in walking-mode (clicking
// outside the canvas, alt-tab, etc.). The click itself is the user gesture
// the browser needs to grant the lock again. Skipped when the collision
// editor is active and the user is brushing — that path deliberately
// releases the lock.
document.getElementById('canvas-host')?.addEventListener('click', () => {
if (viewer.isWalking?.() &&
document.pointerLockElement !== document.querySelector('#canvas-host canvas')) {
const mode = viewer.getCollisionMode?.();
if (mode && mode._lockReleasedForBrush) return;
document.querySelector('#canvas-host canvas')?.requestPointerLock?.();
}
});
// ---------- Collision-editor wiring ----------
const btnCollision = document.getElementById('btn-collision');
const canvasHost = document.getElementById('canvas-host');
let strokeActive = false;
function syncCollisionButton() {
const on = viewer.isCollisionEditor?.();
btnCollision?.setAttribute('aria-pressed', String(!!on));
if (btnCollision) btnCollision.textContent = on ? '⬛ Exit collider' : '⬛ Collider';
}
btnCollision?.addEventListener('click', async () => {
if (viewer.isCollisionEditor?.()) {
viewer.exitCollisionEditor?.();
return;
}
try {
await viewer.enterCollisionEditor?.();
} catch (err) {
console.error('[collision] enter failed:', err);
showError('Load a splat before opening the collision editor.');
}
});
setInterval(syncCollisionButton, 500);
viewer.onCollisionEnter?.(({ mode }) => {
hud.enterCollisionUI({
onBuild: () => {
if (!mode.build()) showError('No usable splat geometry to mesh.');
},
onUndo: () => mode.undo(),
onReset: () => mode.build(),
onExportObj: () => downloadString(mode.exportObj(), 'collider.obj', 'text/plain'),
onSaveSidecar: () => downloadString(mode.exportSidecar(), 'collider.collision.json', 'application/json'),
onIsoChange: (v) => mode.setIso(v),
onToolChange: (v) => {
if (v === 'add' || v === 'remove') {
if (document.pointerLockElement && document.exitPointerLock) {
mode._lockReleasedForBrush = true;
document.exitPointerLock();
}
} else {
mode._lockReleasedForBrush = false;
}
},
onUseColliderChange: (checked) => viewer.setUseMeshCollider?.(checked),
});
hud.setCollisionUseCollider(viewer.isUsingMeshCollider?.() ?? false);
syncCollisionButton();
});
viewer.onCollisionExit?.(() => {
hud.exitCollisionUI();
syncCollisionButton();
});
viewer.onCollisionMeshBuilt?.(({ triCount, iso }) => {
hud.setCollisionStatus(`${triCount.toLocaleString()} tris · iso ${iso.toFixed(2)}`);
// Sync the iso slider to the mode's actual value so a subsequent slider
// drag starts from the right place. Skip when the user IS dragging the
// slider (their input event triggered this rebuild — don't fight them).
const isoSlider = document.getElementById('coll-iso');
if (isoSlider && document.activeElement !== isoSlider) {
isoSlider.value = String(iso);
}
});
function strokeKindNow() {
const t = hud.getCollisionTool();
return (t === 'add' || t === 'remove') ? t : null;
}
canvasHost?.addEventListener('pointerdown', (e) => {
const mode = viewer.getCollisionMode?.();
if (!mode || !mode.editor) return;
const kind = strokeKindNow();
if (!kind) return;
e.preventDefault();
canvasHost.setPointerCapture?.(e.pointerId);
mode.editor.beginStroke(kind);
strokeActive = true;
mode.applyBrushAt(e.clientX, e.clientY, kind, hud.getCollisionBrushSize(), 3, true);
});
canvasHost?.addEventListener('pointermove', (e) => {
if (!strokeActive) return;
const mode = viewer.getCollisionMode?.();
if (!mode) return;
mode.applyBrushAt(e.clientX, e.clientY, strokeKindNow(), hud.getCollisionBrushSize(), 3, true);
});
function endStroke(e) {
if (!strokeActive) return;
strokeActive = false;
const mode = viewer.getCollisionMode?.();
if (mode && mode.editor) {
mode.editor.endStroke();
mode.rebuildMesh();
}
if (e?.pointerId != null) canvasHost?.releasePointerCapture?.(e.pointerId);
}
canvasHost?.addEventListener('pointerup', endStroke);
canvasHost?.addEventListener('pointercancel', endStroke);
function downloadString(text, filename, mime) {
if (text == null) return;
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// A ?src= query loads a hosted (client-delivered) splat instead of the demo —
// auto-framed with a generic pose. Example: viewer.html?src=c/acme-haus.sog
const srcParam = new URLSearchParams(location.search).get('src');
if (srcParam) {
const name = srcParam.split('?')[0].split('/').pop() || 'scene.sog';
load(srcParam, name, { autoFrame: true, forceDefaultPose: true });
} else {
load(DEMO_URL, 'scene.sog');
}