Spaces:
Running
Running
| <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 ; } | |
| </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> | |