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:
parent
5d1941d280
commit
e5e17a71a7
126
llm_team_ui.py
126
llm_team_ui.py
@ -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():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user