// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) // Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). /* BEGIN USAGE */ /** * — 3D object viewer + exporter shell (three.js). * * The stage owns the whole scene: WebGL renderer, neutral studio lighting * with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom, * right-drag to pan), a camera auto-framed to the object's bounds, resize * handling, and a download toolbar that exports the current object as * OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser; * GLB is the interchange format every modern 3D tool imports. * * three.js loads through the page's import map. Include this EXACT pinned * map in , before any module runs — versions and integrity hashes * stay together (same map the "3D object" skill mandates): * * * * Usage: * * * * * * Attributes: * name — export file basename (default "model") * background — CSS color behind the scene (default a warm paper tone) * autorotate — when present, a slow turntable until the user interacts * * Model in real-world meters, centered on the origin, y-up — exports * inherit the scene's units and orientation. The stage fills its own box; * size it with ordinary CSS (default 100vw/100vh page hero). * * Default setup: neutral studio lighting (hemisphere + key + fill), a * soft ground shadow, and NO environment map — so high metalness has * nothing to reflect and renders near-black. Cap metalness around * 0.3–0.4 and carry a metal look with a brighter base color. The copied * file is yours: adjust the lights, shadow, or background in _boot() * when the object needs a different look. */ /* END USAGE */ (() => { const stylesheet = ` :host { position: relative; display: block; width: 100%; height: 100vh; background: var(--stage-bg, #f0eee6); overflow: hidden; } canvas { display: block; outline: none; } .toolbar { position: absolute; right: 16px; bottom: 16px; display: flex; gap: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .toolbar button { appearance: none; border: 1px solid rgba(20, 20, 19, 0.18); border-radius: 8px; background: rgba(255, 255, 255, 0.92); color: #1a1915; font-family: inherit; font-size: 12.5px; font-weight: 500; line-height: 1; padding: 9px 12px; cursor: default; } .toolbar button:hover { background: #fff; } .toolbar button:active { transform: translateY(1px); } .toolbar button[disabled] { opacity: 0.5; pointer-events: none; } .note { position: absolute; left: 16px; bottom: 16px; max-width: 60%; font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: rgba(26, 25, 21, 0.55); user-select: none; } .err { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #8a2f20; text-align: center; white-space: pre-line; } `; function download(blob, filename) { 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), 4000); } /** Tell the host an export attempt settled — telemetry only. The host * (HTMLViewer) verifies the source and re-reads these fields defensively * before counting; nothing else crosses the frame boundary. Guarded so * telemetry can never break the download path. */ function notifyExport(format, ok) { try { window.parent.postMessage( { type: 'omelette:notify-3d-export', format: format, ok: ok === true }, '*' ); } catch (e) {} } class ThreeDStage extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = stylesheet; root.appendChild(style); this._err = document.createElement('div'); this._err.className = 'err'; root.appendChild(this._err); const note = document.createElement('div'); note.className = 'note'; note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan'; root.appendChild(note); this._toolbar = document.createElement('div'); this._toolbar.className = 'toolbar'; this._objBtn = document.createElement('button'); this._objBtn.type = 'button'; this._objBtn.textContent = 'Download OBJ + MTL'; this._objBtn.addEventListener('click', () => this._runExport('obj')); this._glbBtn = document.createElement('button'); this._glbBtn.type = 'button'; this._glbBtn.textContent = 'Download GLB'; this._glbBtn.addEventListener('click', () => this._runExport('glb')); this._toolbar.appendChild(this._objBtn); this._toolbar.appendChild(this._glbBtn); root.appendChild(this._toolbar); this._setButtonsEnabled(false); /** Resolves with { THREE } once the scene is live — build the model * in `await stage.ready` so nothing races the library load. */ this.ready = new Promise((resolve, reject) => { this._readyResolve = resolve; this._readyReject = reject; }); } connectedCallback() { if (this._booted) { // Re-attached after a removal — resume what disconnected stopped. if (this._renderer) { this._renderer.setAnimationLoop(this._loop); this._ro && this._ro.observe(this); } return; } this._booted = true; this._boot().catch((err) => { this._err.style.display = 'flex'; this._err.textContent = 'three.js failed to load.\n' + 'Check that the pinned