// viewer-core.js — three.js scene harness for generated img2threejs factories. // Bundled into every per-job model bundle by esbuild (three included), and // called with the generated factory exports. Runs inside the sandboxed // viewer iframe (and inside standalone.html exports). // // Design notes: // * Generated factories emit `import * as THREE from 'three'` and construct // procedural canvas textures at call time, so this must run in a real // browser (the factory falls back to flat materials when document is // absent — the node smoke test relies on that). // * RoomEnvironment + ACES keeps MeshPhysicalMaterial metals/clearcoat // readable; the generated LookDevLights rig (neutral) is the rig the // upstream review rubric assumes. import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; export class ViewerCaptureError extends Error { constructor(code, message, cause = null) { super(message, cause ? { cause } : undefined); this.name = 'ViewerCaptureError'; this.code = code; } } function requireBoolean(value, option) { if (typeof value !== 'boolean') { throw new TypeError(`${option} must be a boolean`); } return value; } function eachMaterial(root, callback) { const seen = new Set(); root.traverse((node) => { if (!node.material) return; const materials = Array.isArray(node.material) ? node.material : [node.material]; for (const material of materials) { if (!material || seen.has(material)) continue; seen.add(material); callback(material); } }); return seen.size; } // Exported as small, DOM-free helpers so the option behavior can be checked in // Node without constructing a WebGLRenderer. export function applyWireframeOption(root, enabled) { const next = requireBoolean(enabled, 'wireframe'); let materials = 0; eachMaterial(root, (material) => { if (!('wireframe' in material)) return; materials += 1; if (material.wireframe !== next) { material.wireframe = next; material.needsUpdate = true; } }); return { enabled: next, materials }; } export function applyShadowsOption(renderer, ground, enabled) { const next = requireBoolean(enabled, 'shadows'); renderer.shadowMap.enabled = next; renderer.shadowMap.needsUpdate = true; if (ground) ground.visible = next; return next; } export async function captureRendererPng(renderer, scene, camera) { try { renderer.render(scene, camera); } catch (error) { throw new ViewerCaptureError( 'capture-render-failed', `Could not render the screenshot: ${(error && error.message) || String(error)}`, error, ); } const blob = await new Promise((resolve, reject) => { try { renderer.domElement.toBlob((value) => { if (!value) { reject(new ViewerCaptureError( 'capture-encode-failed', 'The renderer did not produce PNG screenshot data.', )); return; } resolve(value); }, 'image/png'); } catch (error) { reject(new ViewerCaptureError( 'capture-encode-failed', `Could not encode the screenshot: ${(error && error.message) || String(error)}`, error, )); } }); return new Promise((resolve, reject) => { let reader; try { reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new ViewerCaptureError( 'capture-read-failed', 'The encoded screenshot could not be read as a data URL.', )); return; } resolve(reader.result); }; reader.onerror = () => reject(new ViewerCaptureError( 'capture-read-failed', 'The browser could not read the encoded screenshot.', reader.error, )); reader.onabort = () => reject(new ViewerCaptureError( 'capture-read-failed', 'Reading the encoded screenshot was interrupted.', )); reader.readAsDataURL(blob); } catch (error) { reject(new ViewerCaptureError( 'capture-read-failed', `Could not read the encoded screenshot: ${(error && error.message) || String(error)}`, error, )); } }); } export function mountViewer(el, makeModel, makeLights, opts = {}) { if (typeof makeModel !== 'function') { throw new Error('No create*Model export found in the generated factory.'); } const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); el.appendChild(renderer.domElement); renderer.domElement.style.display = 'block'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x16181d); const pmrem = new THREE.PMREMGenerator(renderer); const environmentTarget = pmrem.fromScene(new RoomEnvironment(), 0.04); scene.environment = environmentTarget.texture; const model = makeModel({ textureSize: opts.textureSize || 512, qualityPriority: 'balanced', ...(opts.factoryOptions || {}), }); scene.add(model); let lights = null; try { lights = typeof makeLights === 'function' ? makeLights('neutral') : null; } catch (_) { lights = null; } if (lights) { lights.traverse((node) => { if (node.isDirectionalLight && node.shadow && node.shadow.mapSize) { node.shadow.mapSize.set(2048, 2048); } }); scene.add(lights); } else { scene.add(new THREE.HemisphereLight(0xffffff, 0x363b42, 1.1)); } // Ambient floor so a spec that zeroes lights still shows silhouettes. scene.add(new THREE.AmbientLight(0xffffff, 0.15)); const box = new THREE.Box3().setFromObject(model); const sphere = box.getBoundingSphere(new THREE.Sphere()); const radius = Math.max(sphere.radius, 1e-3); const grid = new THREE.GridHelper(radius * 4, 20, 0x3a3f4a, 0x262a33); grid.position.y = box.min.y; scene.add(grid); const ground = new THREE.Mesh( new THREE.CircleGeometry(radius * 2.5, 48), new THREE.ShadowMaterial({ opacity: 0.35 }), ); ground.rotation.x = -Math.PI / 2; ground.position.y = box.min.y - 0.001; ground.receiveShadow = true; scene.add(ground); const camera = new THREE.PerspectiveCamera(40, 1, radius / 100, radius * 100); const dir = new THREE.Vector3(1, 0.6, 1.4).normalize(); const distance = (radius / Math.sin(THREE.MathUtils.degToRad(20))) * 1.1; camera.position.copy(sphere.center).addScaledVector(dir, distance); const controls = new OrbitControls(camera, renderer.domElement); controls.target.copy(sphere.center); controls.enableDamping = true; controls.update(); controls.saveState(); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const fit = () => { const w = el.clientWidth || 640; const h = el.clientHeight || 420; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }; const observer = new ResizeObserver(fit); observer.observe(el); fit(); let meshes = 0; model.traverse((node) => { if (node.isMesh) meshes += 1; }); renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); }); let disposed = false; const optionState = { wireframe: false, shadows: true }; const requireActive = () => { if (disposed) throw new Error('viewer session has been disposed'); }; const renderNow = () => { controls.update(); renderer.render(scene, camera); }; const setWireframe = (enabled) => { requireActive(); const applied = applyWireframeOption(model, enabled); optionState.wireframe = applied.enabled; renderNow(); return optionState.wireframe; }; const setShadows = (enabled) => { requireActive(); optionState.shadows = applyShadowsOption(renderer, ground, enabled); renderNow(); return optionState.shadows; }; const resetCamera = () => { requireActive(); controls.reset(); controls.update(); camera.updateProjectionMatrix(); renderer.render(scene, camera); return true; }; const capture = () => { requireActive(); return captureRendererPng(renderer, scene, camera); }; const dispose = () => { if (disposed) return; disposed = true; observer.disconnect(); renderer.setAnimationLoop(null); controls.dispose(); const geometries = new Set(); const materials = new Set(); const textures = new Set(); scene.traverse((node) => { if (node.geometry) geometries.add(node.geometry); if (node.material) { for (const material of Array.isArray(node.material) ? node.material : [node.material]) { if (!material) continue; materials.add(material); for (const key of Object.keys(material)) { const value = material[key]; if (value && value.isTexture) textures.add(value); } } } }); for (const geometry of geometries) geometry.dispose(); for (const texture of textures) texture.dispose(); for (const material of materials) material.dispose(); environmentTarget.dispose(); pmrem.dispose(); renderer.dispose(); if (typeof renderer.forceContextLoss === 'function') renderer.forceContextLoss(); if (renderer.domElement.parentNode) renderer.domElement.remove(); }; return { capture, dispose, resetCamera, setShadows, setWireframe, get options() { return { ...optionState }; }, stats: { meshes, topLevelChildren: model.children.length, boundingSphereRadius: Number(radius.toFixed(4)), runtimeNodes: Object.keys( (model.userData && model.userData.sculptRuntime && model.userData.sculptRuntime.nodes) || {}, ).length, }, }; }