Interactive 3D viewer: Three.js + GLB scene exploration

- "Explore 3D" button on every response card — opens interactive Three.js viewer
- Orbit controls (drag to rotate, scroll to zoom, auto-rotate)
- 8 procedural GLB scene types: spiral galaxy, toroidal structure, crystal cavern, orbital rings, DNA helix, floating islands, wormhole, lattice world
- Blender exports scenes as GLB in ~1s, cached to disk
- Three.js with ACES filmic tone mapping, fog, 4-point cinematic lighting
- Auto-cleanup: stops rendering when card removed from DOM
- TripoSR pipeline fix: direct ComfyUI call (no self-deadlock)
- AI→3D Sculpt button renamed with clear pipeline labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-30 12:25:47 -05:00
parent 5d1941d280
commit e5e17a71a7

View File

@ -2195,6 +2195,10 @@ HTML = r"""
.card-hero-loading { padding: 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); } .card-hero-loading { padding: 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); }
.card-hero-label { position: absolute; bottom: 0; left: 0; right: 0; padding: 3px 8px; background: linear-gradient(transparent, rgba(0,0,0,0.7)); font-size: 8px; color: var(--text2); font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px; opacity: 0; transition: opacity 0.2s; } .card-hero-label { position: absolute; bottom: 0; left: 0; right: 0; padding: 3px 8px; background: linear-gradient(transparent, rgba(0,0,0,0.7)); font-size: 8px; color: var(--text2); font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px; opacity: 0; transition: opacity 0.2s; }
.card-hero-wrap:hover .card-hero-label { opacity: 1; } .card-hero-wrap:hover .card-hero-label { opacity: 1; }
.viewer-3d { width: 100%; height: 300px; position: relative; background: #050810; cursor: grab; }
.viewer-3d:active { cursor: grabbing; }
.viewer-3d canvas { width: 100% !important; height: 100% !important; display: block; }
.viewer-hint { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: rgba(226,181,90,0.5); pointer-events: none; text-transform: uppercase; letter-spacing: 1px; transition: opacity 1s; }
.output-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 60; } .output-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 60; }
.phase-label { animation: phase-enter 0.5s ease-out both; } .phase-label { animation: phase-enter 0.5s ease-out both; }
@keyframes phase-enter { from { opacity:0; transform: translateX(-20px); } to { opacity:0.8; transform: translateX(0); } } @keyframes phase-enter { from { opacity:0; transform: translateX(-20px); } to { opacity:0.8; transform: translateX(0); } }
@ -2692,6 +2696,12 @@ import { prepare, layout, prepareWithSegments, layoutWithLines } from 'https://e
window._pretext = { prepare, layout, prepareWithSegments, layoutWithLines }; window._pretext = { prepare, layout, prepareWithSegments, layoutWithLines };
import { marked } from 'https://esm.sh/marked@15'; import { marked } from 'https://esm.sh/marked@15';
import DOMPurify from 'https://esm.sh/dompurify@3'; import DOMPurify from 'https://esm.sh/dompurify@3';
import * as THREE from 'https://esm.sh/three@0.170';
import { OrbitControls } from 'https://esm.sh/three@0.170/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'https://esm.sh/three@0.170/addons/loaders/GLTFLoader.js';
window._THREE = THREE;
window._OrbitControls = OrbitControls;
window._GLTFLoader = GLTFLoader;
// Configure marked for clean output // Configure marked for clean output
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
window._renderMarkdown = function(text) { window._renderMarkdown = function(text) {
@ -4262,6 +4272,110 @@ function addIllustrateBtn(cardEl, text) {
}).catch(function() { wrap.remove(); btn3dr.textContent = 'AI\u21923D Sculpt'; btn3dr.disabled = false; }); }).catch(function() { wrap.remove(); btn3dr.textContent = 'AI\u21923D Sculpt'; btn3dr.disabled = false; });
}; };
actions.insertBefore(btn3dr, voteBtn || null); actions.insertBefore(btn3dr, voteBtn || null);
// Explore 3D interactive Three.js viewer
var btn3dv = document.createElement('button'); btn3dv.className = 'card-act';
btn3dv.style.cssText = 'color:#22d3ee;border-color:rgba(34,211,238,0.3)';
btn3dv.textContent = 'Explore 3D';
btn3dv.title = 'Interactive 3D scene — orbit with mouse, zoom with scroll';
btn3dv.onclick = function(e) {
e.stopPropagation();
if (cardEl.querySelector('.viewer-3d')) return;
btn3dv.textContent = 'Loading 3D...'; btn3dv.disabled = true;
launch3DViewer(cardEl);
};
actions.insertBefore(btn3dv, voteBtn || null);
}
function launch3DViewer(cardEl) {
var seed = Math.floor(Math.random() * 99999);
var container = document.createElement('div'); container.className = 'viewer-3d';
var hint = document.createElement('div'); hint.className = 'viewer-hint';
hint.textContent = 'drag to orbit \u2022 scroll to zoom';
container.appendChild(hint);
var body = cardEl.querySelector('.card-body');
if (body) body.parentNode.insertBefore(container, body);
fetch('/api/imagegen/scene-glb', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({seed: seed})
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.glb || !window._THREE) { container.remove(); return; }
var THREE = window._THREE;
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x050810);
scene.fog = new THREE.FogExp2(0x050810, 0.06);
var camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 100);
camera.position.set(5, 3, 5);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.insertBefore(renderer.domElement, hint);
// Lighting
var keyLight = new THREE.DirectionalLight(0xffe0a0, 3);
keyLight.position.set(5, 5, 5); scene.add(keyLight);
var fillLight = new THREE.DirectionalLight(0x8090ff, 1);
fillLight.position.set(-5, 3, -3); scene.add(fillLight);
var ambientLight = new THREE.AmbientLight(0x1a1a2e, 0.5);
scene.add(ambientLight);
var pointLight = new THREE.PointLight(0xffaa44, 2, 15);
pointLight.position.set(0, 2, 0); scene.add(pointLight);
// Orbit controls
var controls = new window._OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.05;
controls.autoRotate = true; controls.autoRotateSpeed = 0.8;
controls.maxDistance = 20; controls.minDistance = 1;
// Load GLB
var loader = new window._GLTFLoader();
var glbBytes = Uint8Array.from(atob(data.glb), function(c) { return c.charCodeAt(0); });
loader.parse(glbBytes.buffer, '', function(gltf) {
scene.add(gltf.scene);
// Center the model
var box = new THREE.Box3().setFromObject(gltf.scene);
var center = box.getCenter(new THREE.Vector3());
gltf.scene.position.sub(center);
// Fade hint after 3s
setTimeout(function() { hint.style.opacity = '0'; }, 3000);
});
// Animate
var animating = true;
function animate() {
if (!animating) return;
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize
var ro = new ResizeObserver(function() {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
ro.observe(container);
// Stop on user interaction to save GPU
container.addEventListener('mouseenter', function() { controls.autoRotate = false; });
container.addEventListener('mouseleave', function() { controls.autoRotate = true; });
// Cleanup when card is removed
var observer = new MutationObserver(function(mutations) {
if (!document.body.contains(container)) {
animating = false; renderer.dispose(); ro.disconnect(); observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}).catch(function() { container.remove(); });
} }
// CARD ACTIONS // CARD ACTIONS
@ -6889,6 +7003,18 @@ def proxy_blender():
return jsonify({"error": str(e)}) return jsonify({"error": str(e)})
@app.route("/api/imagegen/scene-glb", methods=["POST"])
@login_required
def proxy_scene_glb():
"""Get a GLB 3D scene for the interactive Three.js viewer."""
try:
resp = requests.post("http://localhost:3600/scene-glb",
json=request.json, timeout=180)
return jsonify(resp.json())
except Exception as e:
return jsonify({"error": str(e)})
@app.route("/api/imagegen/img-to-3d", methods=["POST"]) @app.route("/api/imagegen/img-to-3d", methods=["POST"])
@login_required @login_required
def proxy_img_to_3d(): def proxy_img_to_3d():