img2threejs / app /static /viewer.html
Mike0021's picture
Polish live viewer and embed verification
8d7dfcd verified
Raw
History Blame Contribute Delete
6.83 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src blob: data:; worker-src blob:">
<title>img2threejs viewer</title>
<style>
html, body { margin: 0; height: 100%; background: #16181d; overflow: hidden; }
#viewer { position: fixed; inset: 0; }
#status {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
color: #9aa0ad; font: 14px/1.5 system-ui, sans-serif; text-align: center; padding: 1rem;
}
#status[hidden] { display: none !important; }
</style>
</head>
<body>
<div id="viewer" role="region" aria-label="3D model viewer"></div>
<div id="status">Loading model…</div>
<script type="module">
// Sandboxed viewer shell. This document runs in an opaque origin
// (sandbox="allow-scripts", no allow-same-origin): the LLM-influenced
// generated code cannot touch the parent application's origin, cookies or
// storage. Communication with the parent is postMessage-only.
//
// Protocol (parent -> iframe):
// {type:'init', bundleText, requestId?} — full ESM bundle
// {type:'set-option', option, value, requestId?} — wireframe/shadows
// {type:'reset-camera', requestId?} — restore initial view
// {type:'capture', requestId?} — request a PNG
// {type:'dispose', requestId?} — release WebGL resources
// Protocol (iframe -> parent):
// {type:'ready', stats, options, requestId?}
// {type:'option-applied', option, value, requestId?}
// {type:'option-error', option, message, requestId?}
// {type:'camera-reset'|'capture'|'disposed', ...}
// {type:'capture-error', code, message, requestId?}
// {type:'error', operation, message, requestId?}
const status = document.getElementById('status');
const viewerEl = document.getElementById('viewer');
let session = null;
let bootVersion = 0;
function post(message) {
// The parent's origin is unknown to us (opaque origin); '*' is acceptable
// here because the payloads contain no secrets and the parent validates
// event.source. Model code never sees these messages.
parent.postMessage(message, '*');
}
function response(message, request) {
const requestId = request && request.requestId;
if (typeof requestId === 'string' || typeof requestId === 'number') {
message.requestId = requestId;
}
return message;
}
function messageOf(error) {
return (error && error.message) || String(error);
}
function showStatus(message) {
status.textContent = message;
status.hidden = false;
}
function disposeSession() {
const active = session;
session = null;
if (!active) return null;
try {
active.dispose();
return null;
} catch (error) {
// A failed generated material disposer must not leave its canvas layered
// over the next session.
viewerEl.replaceChildren();
return error;
}
}
async function boot(bundleText, request) {
const version = ++bootVersion;
disposeSession();
showStatus('Loading model…');
const blob = new Blob([bundleText], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
try {
const mod = await import(url);
if (version !== bootVersion) return;
if (!document.createElement('canvas').getContext('webgl2') &&
!document.createElement('canvas').getContext('webgl')) {
throw new Error('WebGL is not available in this browser or GPU.');
}
const nextSession = mod.mountViewer(viewerEl, mod.makeModel,
typeof mod.makeLights === 'function' ? mod.makeLights : null, {});
if (version !== bootVersion) {
nextSession.dispose();
return;
}
session = nextSession;
status.hidden = true;
post(response({
type: 'ready',
stats: session.stats,
options: session.options,
}, request));
} catch (error) {
if (version !== bootVersion) return;
const message = messageOf(error);
showStatus(`The generated model failed to render: ${message}`);
post(response({ type: 'error', operation: 'init', message }, request));
} finally {
URL.revokeObjectURL(url);
}
}
function setOption(data) {
const option = data.option;
const value = data.value ?? data.enabled;
try {
if (!session) throw new Error('viewer not ready');
if (typeof value !== 'boolean') throw new Error('option value must be a boolean');
if (option === 'wireframe') {
session.setWireframe(value);
} else if (option === 'shadows') {
session.setShadows(value);
} else {
throw new Error(`unsupported viewer option: ${String(option)}`);
}
post(response({ type: 'option-applied', option, value }, data));
} catch (error) {
post(response({
type: 'option-error',
option,
message: messageOf(error),
}, data));
}
}
window.addEventListener('message', (event) => {
if (event.source !== parent) return;
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'init' && typeof data.bundleText === 'string') {
void boot(data.bundleText, data);
} else if (data.type === 'set-option') {
setOption(data);
} else if (data.type === 'reset-camera') {
try {
if (!session) throw new Error('viewer not ready');
session.resetCamera();
post(response({ type: 'camera-reset' }, data));
} catch (error) {
post(response({
type: 'error',
operation: 'reset-camera',
message: messageOf(error),
}, data));
}
} else if (data.type === 'capture') {
try {
if (!session) throw new Error('viewer not ready');
void session.capture().then((dataUrl) => {
post(response({ type: 'capture', dataUrl }, data));
}).catch((error) => {
post(response({
type: 'capture-error',
code: error && error.code ? error.code : 'capture-failed',
message: messageOf(error),
}, data));
});
} catch (error) {
post(response({
type: 'capture-error',
code: 'viewer-not-ready',
message: messageOf(error),
}, data));
}
} else if (data.type === 'dispose') {
++bootVersion;
const error = disposeSession();
showStatus(error ? 'Viewer disposal encountered an error.' : 'Viewer disposed.');
if (error) {
post(response({
type: 'error',
operation: 'dispose',
message: messageOf(error),
}, data));
} else {
post(response({ type: 'disposed' }, data));
}
}
});
// Signal to the parent that the shell itself loaded (watchdog baseline).
post({ type: 'shell-ready' });
</script>
</body>
</html>