Created
January 18, 2026 05:21
-
-
Save bplunkert/916ea52d332bb0815a8eeacb8bf3f82f to your computer and use it in GitHub Desktop.
3d rendering with Claude
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useRef, useEffect, useCallback } from 'react'; | |
| import * as THREE from 'three'; | |
| // OrbitControls implementation | |
| class OrbitControls { | |
| constructor(camera, domElement) { | |
| this.camera = camera; | |
| this.domElement = domElement; | |
| this.target = new THREE.Vector3(); | |
| this.enableDamping = true; | |
| this.dampingFactor = 0.05; | |
| this.rotateSpeed = 0.5; | |
| this.zoomSpeed = 1.0; | |
| this.minDistance = 1; | |
| this.maxDistance = 100; | |
| this.spherical = new THREE.Spherical(); | |
| this.sphericalDelta = new THREE.Spherical(); | |
| this.scale = 1; | |
| this.rotateStart = new THREE.Vector2(); | |
| this.rotateEnd = new THREE.Vector2(); | |
| this.rotateDelta = new THREE.Vector2(); | |
| this.state = -1; | |
| this.STATE = { NONE: -1, ROTATE: 0, ZOOM: 1 }; | |
| this.offset = new THREE.Vector3(); | |
| const position = this.camera.position; | |
| this.offset.copy(position).sub(this.target); | |
| this.spherical.setFromVector3(this.offset); | |
| this.bindEvents(); | |
| } | |
| bindEvents() { | |
| this.domElement.addEventListener('mousedown', this.onMouseDown.bind(this)); | |
| this.domElement.addEventListener('mousemove', this.onMouseMove.bind(this)); | |
| this.domElement.addEventListener('mouseup', this.onMouseUp.bind(this)); | |
| this.domElement.addEventListener('wheel', this.onWheel.bind(this)); | |
| this.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| } | |
| onMouseDown(event) { | |
| event.preventDefault(); | |
| if (event.button === 0) { | |
| this.state = this.STATE.ROTATE; | |
| this.rotateStart.set(event.clientX, event.clientY); | |
| } | |
| } | |
| onMouseMove(event) { | |
| if (this.state === this.STATE.ROTATE) { | |
| this.rotateEnd.set(event.clientX, event.clientY); | |
| this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(this.rotateSpeed); | |
| this.sphericalDelta.theta -= 2 * Math.PI * this.rotateDelta.x / this.domElement.clientHeight; | |
| this.sphericalDelta.phi -= 2 * Math.PI * this.rotateDelta.y / this.domElement.clientHeight; | |
| this.rotateStart.copy(this.rotateEnd); | |
| } | |
| } | |
| onMouseUp() { | |
| this.state = this.STATE.NONE; | |
| } | |
| onWheel(event) { | |
| event.preventDefault(); | |
| if (event.deltaY < 0) { | |
| this.scale /= Math.pow(0.95, this.zoomSpeed); | |
| } else { | |
| this.scale *= Math.pow(0.95, this.zoomSpeed); | |
| } | |
| } | |
| update() { | |
| this.offset.copy(this.camera.position).sub(this.target); | |
| this.spherical.setFromVector3(this.offset); | |
| if (this.enableDamping) { | |
| this.spherical.theta += this.sphericalDelta.theta * this.dampingFactor; | |
| this.spherical.phi += this.sphericalDelta.phi * this.dampingFactor; | |
| } else { | |
| this.spherical.theta += this.sphericalDelta.theta; | |
| this.spherical.phi += this.sphericalDelta.phi; | |
| } | |
| this.spherical.phi = Math.max(0.01, Math.min(Math.PI - 0.01, this.spherical.phi)); | |
| this.spherical.radius *= this.scale; | |
| this.spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, this.spherical.radius)); | |
| this.offset.setFromSpherical(this.spherical); | |
| this.camera.position.copy(this.target).add(this.offset); | |
| this.camera.lookAt(this.target); | |
| if (this.enableDamping) { | |
| this.sphericalDelta.theta *= (1 - this.dampingFactor); | |
| this.sphericalDelta.phi *= (1 - this.dampingFactor); | |
| } else { | |
| this.sphericalDelta.set(0, 0, 0); | |
| } | |
| this.scale = 1; | |
| } | |
| dispose() { | |
| this.domElement.removeEventListener('mousedown', this.onMouseDown); | |
| this.domElement.removeEventListener('mousemove', this.onMouseMove); | |
| this.domElement.removeEventListener('mouseup', this.onMouseUp); | |
| this.domElement.removeEventListener('wheel', this.onWheel); | |
| } | |
| } | |
| export default function ThreeJSGenerator() { | |
| const [prompt, setPrompt] = useState(''); | |
| const [image, setImage] = useState(null); | |
| const [imagePreview, setImagePreview] = useState(null); | |
| const [iterations, setIterations] = useState(3); | |
| const [currentIteration, setCurrentIteration] = useState(0); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [status, setStatus] = useState(''); | |
| const [generatedCode, setGeneratedCode] = useState(''); | |
| const [feedback, setFeedback] = useState([]); | |
| const [error, setError] = useState(''); | |
| const [comparisonView, setComparisonView] = useState(null); | |
| const canvasRef = useRef(null); | |
| const sceneRef = useRef(null); | |
| const cameraRef = useRef(null); | |
| const rendererRef = useRef(null); | |
| const controlsRef = useRef(null); | |
| const animationRef = useRef(null); | |
| const objectRef = useRef(null); | |
| // Initialize Three.js scene | |
| const initScene = useCallback(() => { | |
| if (!canvasRef.current) return; | |
| // Clean up existing | |
| if (rendererRef.current) { | |
| rendererRef.current.dispose(); | |
| } | |
| if (animationRef.current) { | |
| cancelAnimationFrame(animationRef.current); | |
| } | |
| const width = canvasRef.current.clientWidth; | |
| const height = canvasRef.current.clientHeight; | |
| // Scene | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x1a1a2e); | |
| sceneRef.current = scene; | |
| // Camera | |
| const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); | |
| camera.position.set(5, 5, 5); | |
| cameraRef.current = camera; | |
| // Renderer | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); | |
| renderer.setSize(width, height); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| canvasRef.current.innerHTML = ''; | |
| canvasRef.current.appendChild(renderer.domElement); | |
| rendererRef.current = renderer; | |
| // Controls | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controlsRef.current = controls; | |
| // Lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1); | |
| directionalLight.position.set(10, 10, 10); | |
| directionalLight.castShadow = true; | |
| scene.add(directionalLight); | |
| const pointLight = new THREE.PointLight(0x4fc3f7, 0.5); | |
| pointLight.position.set(-5, 5, -5); | |
| scene.add(pointLight); | |
| // Grid helper | |
| const gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222); | |
| scene.add(gridHelper); | |
| // Animation loop | |
| const animate = () => { | |
| animationRef.current = requestAnimationFrame(animate); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| }; | |
| animate(); | |
| }, []); | |
| useEffect(() => { | |
| initScene(); | |
| return () => { | |
| if (animationRef.current) { | |
| cancelAnimationFrame(animationRef.current); | |
| } | |
| if (rendererRef.current) { | |
| rendererRef.current.dispose(); | |
| } | |
| }; | |
| }, [initScene]); | |
| // Execute generated Three.js code | |
| const executeCode = useCallback((code) => { | |
| if (!sceneRef.current) return; | |
| // Remove old object | |
| if (objectRef.current) { | |
| sceneRef.current.remove(objectRef.current); | |
| objectRef.current = null; | |
| } | |
| try { | |
| // Create a function that has access to THREE | |
| const createObject = new Function('THREE', 'scene', ` | |
| try { | |
| ${code} | |
| return object; | |
| } catch (e) { | |
| console.error('Code execution error:', e); | |
| return null; | |
| } | |
| `); | |
| const object = createObject(THREE, sceneRef.current); | |
| if (object) { | |
| objectRef.current = object; | |
| sceneRef.current.add(object); | |
| } | |
| } catch (e) { | |
| console.error('Failed to execute code:', e); | |
| setError(`Code execution error: ${e.message}`); | |
| } | |
| }, []); | |
| // Capture screenshot at specific rotation | |
| const captureScreenshot = useCallback((rotationY = 0) => { | |
| return new Promise((resolve) => { | |
| if (!rendererRef.current || !sceneRef.current || !cameraRef.current || !objectRef.current) { | |
| resolve(null); | |
| return; | |
| } | |
| // Save original rotation | |
| const originalRotation = objectRef.current.rotation.y; | |
| // Set rotation | |
| objectRef.current.rotation.y = rotationY; | |
| // Render | |
| rendererRef.current.render(sceneRef.current, cameraRef.current); | |
| // Capture | |
| const dataUrl = rendererRef.current.domElement.toDataURL('image/png'); | |
| // Restore rotation | |
| objectRef.current.rotation.y = originalRotation; | |
| resolve(dataUrl); | |
| }); | |
| }, []); | |
| // Capture multiple screenshots | |
| const captureMultipleScreenshots = useCallback(async () => { | |
| const screenshots = []; | |
| const rotations = [0, Math.PI / 2, Math.PI, Math.PI * 1.5]; | |
| for (const rotation of rotations) { | |
| const screenshot = await captureScreenshot(rotation); | |
| if (screenshot) { | |
| screenshots.push(screenshot); | |
| } | |
| await new Promise(r => setTimeout(r, 100)); | |
| } | |
| return screenshots; | |
| }, [captureScreenshot]); | |
| // Call Claude API | |
| const callClaude = async (messages) => { | |
| const response = await fetch('https://api.anthropic.com/v1/messages', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| model: 'claude-sonnet-4-20250514', | |
| max_tokens: 4096, | |
| messages: messages, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| return data.content.map(c => c.text || '').join(''); | |
| }; | |
| // Generate initial Three.js code | |
| const generateInitialCode = async () => { | |
| const content = []; | |
| if (image) { | |
| content.push({ | |
| type: 'text', | |
| text: 'Here is the reference image I want you to recreate as a 3D model:', | |
| }); | |
| content.push({ | |
| type: 'image', | |
| source: { | |
| type: 'base64', | |
| media_type: image.type, | |
| data: image.data, | |
| }, | |
| }); | |
| } | |
| content.push({ | |
| type: 'text', | |
| text: `Generate Three.js code to create a 3D representation of: "${prompt}" | |
| ${image ? `IMPORTANT: Study the reference image carefully. Pay close attention to: | |
| - The overall shape and silhouette | |
| - Proportions and relative sizes of parts | |
| - Colors and materials | |
| - Key distinguishing features | |
| - Pose/orientation of the object | |
| Your goal is to create a 3D model that closely matches this reference image.` : ''} | |
| IMPORTANT: Return ONLY the JavaScript code, no markdown, no explanation. | |
| The code should: | |
| 1. Create a variable called 'object' that is a THREE.Group or THREE.Mesh | |
| 2. Use only built-in THREE.js geometries and materials | |
| 3. Match the reference image as closely as possible (colors, proportions, features) | |
| 4. Include any necessary child meshes in the group | |
| 5. Center the object at origin | |
| 6. Make the object a reasonable size (fits in a 5x5x5 box) | |
| Example format: | |
| const object = new THREE.Group(); | |
| const geometry = new THREE.BoxGeometry(1, 1, 1); | |
| const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 }); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| object.add(mesh); | |
| Now generate the code for: "${prompt}"`, | |
| }); | |
| return callClaude([{ role: 'user', content }]); | |
| }; | |
| // Get feedback by comparing to reference image | |
| const getFeedbackWithComparison = async (screenshots, currentCode, iterationNum) => { | |
| const content = []; | |
| // First show the reference image if available | |
| if (image) { | |
| content.push({ | |
| type: 'text', | |
| text: `## REFERENCE IMAGE (TARGET) | |
| This is the original reference image that we're trying to recreate as a 3D model:`, | |
| }); | |
| content.push({ | |
| type: 'image', | |
| source: { | |
| type: 'base64', | |
| media_type: image.type, | |
| data: image.data, | |
| }, | |
| }); | |
| } | |
| // Then show current renders | |
| content.push({ | |
| type: 'text', | |
| text: `## CURRENT 3D RENDER (Iteration ${iterationNum}) | |
| Here are screenshots of the current 3D model from 4 angles (front, right, back, left):`, | |
| }); | |
| for (let i = 0; i < screenshots.length; i++) { | |
| const base64Data = screenshots[i].split(',')[1]; | |
| content.push({ | |
| type: 'image', | |
| source: { | |
| type: 'base64', | |
| media_type: 'image/png', | |
| data: base64Data, | |
| }, | |
| }); | |
| } | |
| // Comparison prompt | |
| content.push({ | |
| type: 'text', | |
| text: `## TASK: Compare and Improve | |
| Object description: "${prompt}" | |
| Current Three.js code: | |
| \`\`\`javascript | |
| ${currentCode} | |
| \`\`\` | |
| ${image ? `**CRITICAL: Compare the current 3D render directly to the reference image above.** | |
| Analyze the following aspects and identify SPECIFIC differences: | |
| 1. **Shape Accuracy**: Does the overall silhouette match? Are there missing or incorrect geometric shapes? | |
| 2. **Proportions**: Are the relative sizes of different parts correct compared to the reference? | |
| 3. **Colors**: Do the colors match the reference image? List specific color corrections needed. | |
| 4. **Details**: What features from the reference are missing or need refinement? | |
| 5. **Pose/Orientation**: Is the object oriented correctly compared to the reference? | |
| For each difference you identify, explain HOW to fix it in the code.` : `Analyze the current render and suggest improvements to make it look more like "${prompt}".`} | |
| Then provide IMPROVED Three.js code that addresses these specific differences. | |
| The code must create a variable called 'object' that is a THREE.Group or THREE.Mesh. | |
| Return the improved code in a \`\`\`javascript code block.`, | |
| }); | |
| const response = await callClaude([{ role: 'user', content }]); | |
| // Update comparison view for UI | |
| if (screenshots.length > 0) { | |
| setComparisonView({ | |
| reference: imagePreview, | |
| current: screenshots[0], | |
| iteration: iterationNum | |
| }); | |
| } | |
| return response; | |
| }; | |
| // Extract code from response | |
| const extractCode = (response) => { | |
| // Try to find code block | |
| const codeBlockMatch = response.match(/```(?:javascript|js)?\s*([\s\S]*?)```/); | |
| if (codeBlockMatch) { | |
| return codeBlockMatch[1].trim(); | |
| } | |
| // If no code block, try to find code pattern | |
| const objectMatch = response.match(/(const\s+object\s*=[\s\S]*?)(?=\n\n|$)/); | |
| if (objectMatch) { | |
| return objectMatch[1].trim(); | |
| } | |
| // Return full response if it looks like code | |
| if (response.includes('THREE.') && response.includes('object')) { | |
| return response.trim(); | |
| } | |
| return null; | |
| }; | |
| // Extract comparison analysis from response | |
| const extractAnalysis = (response) => { | |
| // Get text before code block | |
| const parts = response.split('```'); | |
| if (parts.length > 0) { | |
| return parts[0].trim(); | |
| } | |
| return response.slice(0, 800); | |
| }; | |
| // Main generation loop | |
| const handleGenerate = async () => { | |
| if (!prompt.trim()) { | |
| setError('Please enter a prompt'); | |
| return; | |
| } | |
| setIsGenerating(true); | |
| setError(''); | |
| setFeedback([]); | |
| setCurrentIteration(0); | |
| setComparisonView(null); | |
| try { | |
| // Generate initial code | |
| setStatus('Analyzing reference and generating initial 3D model...'); | |
| const initialResponse = await generateInitialCode(); | |
| let currentCode = extractCode(initialResponse) || initialResponse; | |
| setGeneratedCode(currentCode); | |
| executeCode(currentCode); | |
| // Wait for render | |
| await new Promise(r => setTimeout(r, 500)); | |
| // Iteration loop | |
| for (let i = 0; i < iterations; i++) { | |
| setCurrentIteration(i + 1); | |
| setStatus(`Iteration ${i + 1}/${iterations}: Capturing screenshots...`); | |
| // Capture screenshots | |
| const screenshots = await captureMultipleScreenshots(); | |
| if (screenshots.length === 0) { | |
| setFeedback(prev => [...prev, { | |
| iteration: i + 1, | |
| text: 'Failed to capture screenshots', | |
| differences: [] | |
| }]); | |
| continue; | |
| } | |
| setStatus(`Iteration ${i + 1}/${iterations}: Comparing to reference image...`); | |
| // Get feedback with comparison | |
| const feedbackResponse = await getFeedbackWithComparison(screenshots, currentCode, i + 1); | |
| // Extract analysis | |
| const analysisText = extractAnalysis(feedbackResponse); | |
| // Parse out specific differences if possible | |
| const differences = []; | |
| const diffPatterns = [ | |
| /shape accuracy[:\s]*(.*?)(?=\n\d|\n\*\*|$)/is, | |
| /proportions[:\s]*(.*?)(?=\n\d|\n\*\*|$)/is, | |
| /colors?[:\s]*(.*?)(?=\n\d|\n\*\*|$)/is, | |
| /details?[:\s]*(.*?)(?=\n\d|\n\*\*|$)/is, | |
| ]; | |
| diffPatterns.forEach(pattern => { | |
| const match = analysisText.match(pattern); | |
| if (match && match[1]) { | |
| differences.push(match[1].trim().slice(0, 200)); | |
| } | |
| }); | |
| setFeedback(prev => [...prev, { | |
| iteration: i + 1, | |
| text: analysisText.slice(0, 600), | |
| differences: differences | |
| }]); | |
| // Extract and apply new code | |
| const newCode = extractCode(feedbackResponse); | |
| if (newCode && newCode !== currentCode) { | |
| currentCode = newCode; | |
| setGeneratedCode(currentCode); | |
| setStatus(`Iteration ${i + 1}/${iterations}: Applying improvements...`); | |
| executeCode(currentCode); | |
| // Wait for render | |
| await new Promise(r => setTimeout(r, 500)); | |
| } | |
| } | |
| setStatus('Generation complete!'); | |
| // Final comparison | |
| if (image) { | |
| const finalScreenshots = await captureMultipleScreenshots(); | |
| if (finalScreenshots.length > 0) { | |
| setComparisonView({ | |
| reference: imagePreview, | |
| current: finalScreenshots[0], | |
| iteration: 'Final' | |
| }); | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Generation error:', e); | |
| setError(`Generation failed: ${e.message}`); | |
| setStatus(''); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| // Handle image upload | |
| const handleImageUpload = (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const base64 = event.target.result; | |
| setImagePreview(base64); | |
| setImage({ | |
| type: file.type, | |
| data: base64.split(',')[1], | |
| }); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gray-900 text-white p-4"> | |
| <div className="max-w-7xl mx-auto"> | |
| <h1 className="text-3xl font-bold mb-2 text-center bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent"> | |
| Three.js AI Generator | |
| </h1> | |
| <p className="text-center text-gray-400 mb-6 text-sm"> | |
| Upload a reference image and watch Claude iteratively refine the 3D model to match it | |
| </p> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> | |
| {/* Left Panel - Controls */} | |
| <div className="space-y-4"> | |
| {/* Prompt Input */} | |
| <div className="bg-gray-800 rounded-lg p-4"> | |
| <label className="block text-sm font-medium mb-2">Describe the object</label> | |
| <textarea | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| placeholder="e.g., A cute low-poly fox sitting down" | |
| className="w-full bg-gray-700 rounded-lg p-3 text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none" | |
| rows={3} | |
| /> | |
| </div> | |
| {/* Image Upload */} | |
| <div className="bg-gray-800 rounded-lg p-4"> | |
| <label className="block text-sm font-medium mb-2"> | |
| Reference Image <span className="text-blue-400">(recommended)</span> | |
| </label> | |
| <input | |
| type="file" | |
| accept="image/*" | |
| onChange={handleImageUpload} | |
| className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700" | |
| /> | |
| {imagePreview && ( | |
| <div className="mt-3"> | |
| <p className="text-xs text-gray-400 mb-1">Reference:</p> | |
| <img src={imagePreview} alt="Reference" className="max-h-32 rounded-lg border border-gray-600" /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Iterations */} | |
| <div className="bg-gray-800 rounded-lg p-4"> | |
| <label className="block text-sm font-medium mb-2"> | |
| Refinement Iterations: {iterations} | |
| </label> | |
| <input | |
| type="range" | |
| min="1" | |
| max="5" | |
| value={iterations} | |
| onChange={(e) => setIterations(parseInt(e.target.value))} | |
| className="w-full accent-blue-500" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1"> | |
| Each iteration compares to reference and refines the model | |
| </p> | |
| </div> | |
| {/* Generate Button */} | |
| <button | |
| onClick={handleGenerate} | |
| disabled={isGenerating || !prompt.trim()} | |
| className={`w-full py-3 rounded-lg font-semibold transition-all ${ | |
| isGenerating || !prompt.trim() | |
| ? 'bg-gray-600 cursor-not-allowed' | |
| : 'bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700' | |
| }`} | |
| > | |
| {isGenerating ? 'Generating...' : 'Generate 3D Model'} | |
| </button> | |
| {/* Status */} | |
| {status && ( | |
| <div className="bg-blue-900/50 rounded-lg p-4 border border-blue-700"> | |
| <div className="flex items-center gap-3"> | |
| <div className="animate-spin w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full" /> | |
| <span className="text-sm">{status}</span> | |
| </div> | |
| {currentIteration > 0 && ( | |
| <div className="mt-2"> | |
| <div className="bg-gray-700 rounded-full h-2"> | |
| <div | |
| className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full transition-all duration-500" | |
| style={{ width: `${(currentIteration / iterations) * 100}%` }} | |
| /> | |
| </div> | |
| <p className="text-xs text-gray-400 mt-1">Iteration {currentIteration} of {iterations}</p> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Error */} | |
| {error && ( | |
| <div className="bg-red-900/50 rounded-lg p-4 border border-red-700 text-red-200 text-sm"> | |
| {error} | |
| </div> | |
| )} | |
| </div> | |
| {/* Middle Panel - 3D Viewer & Comparison */} | |
| <div className="space-y-4"> | |
| {/* 3D Canvas */} | |
| <div className="bg-gray-800 rounded-lg overflow-hidden"> | |
| <div className="bg-gray-700 px-4 py-2 text-sm font-medium"> | |
| 3D Preview (drag to rotate, scroll to zoom) | |
| </div> | |
| <div | |
| ref={canvasRef} | |
| className="w-full aspect-square" | |
| style={{ minHeight: '350px' }} | |
| /> | |
| </div> | |
| {/* Side-by-side Comparison */} | |
| {comparisonView && comparisonView.reference && ( | |
| <div className="bg-gray-800 rounded-lg p-4"> | |
| <h3 className="text-sm font-medium mb-3 text-purple-400"> | |
| Comparison - Iteration {comparisonView.iteration} | |
| </h3> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div> | |
| <p className="text-xs text-gray-400 mb-1">Reference</p> | |
| <img | |
| src={comparisonView.reference} | |
| alt="Reference" | |
| className="w-full rounded border border-gray-600" | |
| /> | |
| </div> | |
| <div> | |
| <p className="text-xs text-gray-400 mb-1">Current Render</p> | |
| <img | |
| src={comparisonView.current} | |
| alt="Current" | |
| className="w-full rounded border border-gray-600" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Panel - Feedback & Code */} | |
| <div className="space-y-4"> | |
| {/* Feedback History */} | |
| {feedback.length > 0 && ( | |
| <div className="bg-gray-800 rounded-lg p-4 max-h-80 overflow-y-auto"> | |
| <h3 className="font-semibold mb-3 text-purple-400">Comparison Feedback</h3> | |
| {feedback.map((f, i) => ( | |
| <div key={i} className="mb-4 pb-3 border-b border-gray-700 last:border-0"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="bg-blue-600 text-xs px-2 py-0.5 rounded"> | |
| Iteration {f.iteration} | |
| </span> | |
| </div> | |
| {f.differences && f.differences.length > 0 && ( | |
| <div className="mb-2"> | |
| <p className="text-xs text-yellow-400 mb-1">Key Differences Found:</p> | |
| <ul className="text-xs text-gray-300 space-y-1"> | |
| {f.differences.map((d, j) => ( | |
| <li key={j} className="flex items-start gap-1"> | |
| <span className="text-yellow-500">•</span> | |
| <span>{d}</span> | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| <div className="text-xs text-gray-400 whitespace-pre-wrap"> | |
| {f.text.slice(0, 400)}... | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Generated Code */} | |
| {generatedCode && ( | |
| <div className="bg-gray-800 rounded-lg overflow-hidden"> | |
| <div className="bg-gray-700 px-4 py-2 text-sm font-medium flex justify-between items-center"> | |
| <span>Generated Code</span> | |
| <button | |
| onClick={() => navigator.clipboard.writeText(generatedCode)} | |
| className="text-xs bg-gray-600 px-2 py-1 rounded hover:bg-gray-500" | |
| > | |
| Copy | |
| </button> | |
| </div> | |
| <pre className="p-4 text-xs text-green-400 overflow-x-auto max-h-48 overflow-y-auto"> | |
| {generatedCode} | |
| </pre> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment