diff --git a/llm_team_ui.py b/llm_team_ui.py index 7dc9a03..8f9cbe3 100644 --- a/llm_team_ui.py +++ b/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-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; } + .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; } .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); } } @@ -2692,6 +2696,12 @@ import { prepare, layout, prepareWithSegments, layoutWithLines } from 'https://e window._pretext = { prepare, layout, prepareWithSegments, layoutWithLines }; import { marked } from 'https://esm.sh/marked@15'; 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 marked.setOptions({ breaks: true, gfm: true }); window._renderMarkdown = function(text) { @@ -4262,6 +4272,110 @@ function addIllustrateBtn(cardEl, text) { }).catch(function() { wrap.remove(); btn3dr.textContent = 'AI\u21923D Sculpt'; btn3dr.disabled = false; }); }; 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 ──────────────────────────────────── @@ -6889,6 +7003,18 @@ def proxy_blender(): 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"]) @login_required def proxy_img_to_3d():