Spaces:
Running
Running
| import gradio as gr | |
| import numpy as np | |
| import trimesh | |
| import tempfile | |
| import os | |
| import struct | |
| import logging | |
| from pathlib import Path | |
| from typing import Tuple | |
| import shutil | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| class VoxParser: | |
| """MagicaVoxel .vox file parser""" | |
| def __init__(self, file_path): | |
| self.file_path = file_path | |
| def parse(self) -> dict: | |
| """Parse the .vox file structure""" | |
| try: | |
| with open(self.file_path, 'rb') as f: | |
| data = f.read() | |
| offset = 0 | |
| # Read header | |
| header = data[offset:offset+4].decode('ascii', errors='ignore') | |
| offset += 4 | |
| if header != 'VOX ': | |
| raise ValueError("Invalid VOX file header") | |
| version = struct.unpack('<I', data[offset:offset+4])[0] | |
| offset += 4 | |
| voxels = [] | |
| palette = [] | |
| size = {} | |
| # Parse chunks | |
| while offset < len(data): | |
| chunk_id = data[offset:offset+4].decode('ascii', errors='ignore') | |
| offset += 4 | |
| chunk_size = struct.unpack('<I', data[offset:offset+4])[0] | |
| offset += 4 | |
| child_size = struct.unpack('<I', data[offset:offset+4])[0] | |
| offset += 4 | |
| if chunk_id == 'SIZE': | |
| size = { | |
| 'x': struct.unpack('<I', data[offset:offset+4])[0], | |
| 'y': struct.unpack('<I', data[offset+4:offset+8])[0], | |
| 'z': struct.unpack('<I', data[offset+8:offset+12])[0] | |
| } | |
| offset += chunk_size | |
| elif chunk_id == 'XYZI': | |
| num_voxels = struct.unpack('<I', data[offset:offset+4])[0] | |
| offset += 4 | |
| for i in range(num_voxels): | |
| if offset + 4 <= len(data): | |
| x, y, z, color_index = struct.unpack('BBBB', data[offset:offset+4]) | |
| voxels.append({ | |
| 'x': x, | |
| 'y': y, | |
| 'z': z, | |
| 'color_index': color_index | |
| }) | |
| offset += 4 | |
| elif chunk_id == 'RGBA': | |
| for i in range(256): | |
| if offset + 4 <= len(data): | |
| r, g, b, a = struct.unpack('BBBB', data[offset:offset+4]) | |
| palette.append({'r': r, 'g': g, 'b': b, 'a': a}) | |
| offset += 4 | |
| else: | |
| offset += chunk_size | |
| if offset >= len(data): | |
| break | |
| return { | |
| 'voxels': voxels, | |
| 'palette': palette or self._default_palette(), | |
| 'size': size | |
| } | |
| except Exception as e: | |
| raise ValueError(f"Error parsing VOX file: {e}") | |
| def _default_palette(self) -> list: | |
| colors = [] | |
| for i in range(256): | |
| intensity = i / 255.0 | |
| colors.append({ | |
| 'r': int(intensity * 255), | |
| 'g': int(intensity * 255), | |
| 'b': int(intensity * 255), | |
| 'a': 255 | |
| }) | |
| return colors | |
| class VoxToGlbConverter: | |
| """MagicaVoxel to GLB converter""" | |
| def __init__(self): | |
| self.voxel_size = 1.0 | |
| def vox_to_glb(self, vox_file_path: str) -> Tuple[str, str]: | |
| """Convert .vox file to .glb file""" | |
| try: | |
| parser = VoxParser(vox_file_path) | |
| voxel_data = parser.parse() | |
| if not voxel_data['voxels']: | |
| return "", "No voxels found in the file" | |
| mesh = self.create_mesh_from_voxels(voxel_data) | |
| output_path = str(Path(tempfile.gettempdir()) / f"converted_model.glb") | |
| mesh.export(output_path) | |
| voxel_count = len(voxel_data['voxels']) | |
| return output_path, f"Converted {voxel_count} voxels to GLB format" | |
| except Exception as e: | |
| logger.error(f"Conversion error: {e}") | |
| return "", f"Error converting file: {str(e)}" | |
| def create_mesh_from_voxels(self, voxel_data: dict) -> trimesh.Trimesh: | |
| """Create mesh from voxel data""" | |
| voxels = voxel_data['voxels'] | |
| palette = voxel_data['palette'] | |
| color_groups = {} | |
| for voxel in voxels: | |
| color_idx = voxel['color_index'] | |
| if color_idx not in color_groups: | |
| color_groups[color_idx] = [] | |
| color_groups[color_idx].append(voxel) | |
| meshes = [] | |
| for color_idx, voxels in color_groups.items(): | |
| color = palette[color_idx] if color_idx < len(palette) else {'r': 255, 'g': 255, 'b': 255, 'a': 255} | |
| cube = trimesh.creation.box(extents=[self.voxel_size, self.voxel_size, self.voxel_size]) | |
| for voxel in voxels: | |
| translation = trimesh.transformations.translation_matrix([ | |
| voxel['x'] * self.voxel_size, | |
| voxel['z'] * self.voxel_size, | |
| voxel['y'] * self.voxel_size | |
| ]) | |
| transformed_cube = cube.copy() | |
| transformed_cube.apply_transform(translation) | |
| vertex_colors = np.tile([color['r']/255, color['g']/255, color['b']/255, color['a']/255], | |
| (len(transformed_cube.vertices), 1)) | |
| transformed_cube.visual.vertex_colors = vertex_colors | |
| meshes.append(transformed_cube) | |
| if meshes: | |
| combined = trimesh.util.concatenate(meshes) | |
| return combined | |
| else: | |
| return trimesh.creation.box(extents=[self.voxel_size, self.voxel_size, self.voxel_size]) | |
| def process_vox_file(vox_file) -> Tuple[str, str]: | |
| """Process uploaded .vox file and convert to .glb""" | |
| if vox_file is None: | |
| return "", "Please upload a .vox file" | |
| try: | |
| converter = VoxToGlbConverter() | |
| if hasattr(vox_file, 'name'): | |
| real_file_path = vox_file.name | |
| if os.path.isfile(real_file_path): | |
| glb_path, message = converter.vox_to_glb(real_file_path) | |
| return glb_path, message | |
| else: | |
| return "", "Could not access uploaded file" | |
| else: | |
| return "", "Invalid file format" | |
| except Exception as e: | |
| return "", f"Error: {str(e)}" | |
| def create_glb_preview(glb_file): | |
| """Create HTML preview for GLB file - FIXED SYNTAX ERROR""" | |
| if glb_file is None: | |
| return "<p>No GLB file to preview</p>" | |
| if hasattr(glb_file, 'name'): | |
| glb_path = glb_file.name | |
| else: | |
| glb_path = str(glb_file) | |
| if not os.path.exists(glb_path): | |
| return "<p>GLB file not found</p>" | |
| # Create HTML with Three.js viewer - FIXED THE SYNTAX ERROR | |
| html_content = f""" | |
| <div style="width: 100%; height: 400px; position: relative; background: #1a1a1a; border-radius: 8px; overflow: hidden;"> | |
| <div id="preview-container" style="width: 100%; height: 100%;"></div> | |
| <div style="position: absolute; top: 10px; left: 10px; color: white; font-size: 12px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 4px;"> | |
| ๐ฎ Drag to rotate โข Scroll to zoom โข Right-click to pan | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/GLTFLoader.js"></script> | |
| <script> | |
| // Initialize Three.js scene | |
| const container = document.getElementById('preview-container'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x2a2a2a); | |
| const camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000); | |
| camera.position.set(5, 5, 5); | |
| const renderer = new THREE.WebGLRenderer({{ antialias: true }}); | |
| renderer.setSize(container.clientWidth, container.clientHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| container.appendChild(renderer.domElement); | |
| // Controls | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.target.set(0, 0, 0); | |
| // Lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(10, 10, 10); | |
| directionalLight.castShadow = true; | |
| scene.add(directionalLight); | |
| // Grid helper | |
| const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x333333); | |
| scene.add(gridHelper); | |
| // Load GLB file | |
| const loader = new THREE.GLTFLoader(); | |
| const glbPath = '{glb_path}'; | |
| loader.load( | |
| glbPath, | |
| function (gltf) {{ | |
| const model = gltf.scene; | |
| // Center and scale the model | |
| const box = new THREE.Box3().setFromObject(model); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const size = box.getSize(new THREE.Vector3()); | |
| // Center the model | |
| model.position.sub(center); | |
| // Auto-scale to fit view | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 10 / maxDim; | |
| model.scale.multiplyScalar(scale); | |
| scene.add(model); | |
| // Auto-rotate camera | |
| function animate() {{ | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| // Gentle auto-rotation | |
| const time = Date.now() * 0.001; | |
| camera.position.x = Math.cos(time * 0.1) * 8; | |
| camera.position.z = Math.sin(time * 0.1) * 8; | |
| camera.lookAt(0, 0, 0); | |
| renderer.render(scene, camera); | |
| }} | |
| animate(); | |
| }}, | |
| function (progress) {{ | |
| console.log('Loading:', progress); | |
| }}, | |
| function (error) {{ | |
| console.error('Error loading GLB:', error); | |
| container.innerHTML = '<p style="color: white; text-align: center; padding: 20px;">Error loading GLB file</p>'; | |
| }} | |
| ); | |
| // Handle resize | |
| window.addEventListener('resize', function() {{ | |
| camera.aspect = container.clientWidth / container.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(container.clientWidth, container.clientHeight); | |
| }}); | |
| </script> | |
| """ | |
| return html_content | |
| def create_gradio_interface(): | |
| with gr.Blocks(title="VOX to GLB Converter with Preview", theme=gr.themes.Soft()) as app: | |
| gr.Markdown(""" | |
| # ๐ง MagicaVoxel VOX to GLB Converter with 3D Preview | |
| Convert your MagicaVoxel `.vox` files to `.glb` format and preview them in 3D | |
| """) | |
| with gr.Row(): | |
| with gr.Column(): | |
| vox_input = gr.File(label="Upload VOX File", file_types=[".vox"], file_count="single") | |
| convert_btn = gr.Button("๐ Convert to GLB", variant="primary") | |
| status_output = gr.Textbox(label="Status", interactive=False, placeholder="Ready...") | |
| with gr.Column(): | |
| glb_output = gr.File(label="Download GLB File", file_types=[".glb"], interactive=False) | |
| # 3D Preview Section | |
| gr.Markdown("### ๐ฎ 3D Preview") | |
| preview_html = gr.HTML(label="GLB Preview") | |
| # Connect conversion to preview | |
| def convert_with_preview(vox_file): | |
| glb_path, message = process_vox_file(vox_file) | |
| if glb_path: | |
| preview = create_glb_preview(glb_path) | |
| return glb_path, message, preview | |
| else: | |
| return None, message, "<p>โ Conversion failed - no preview available</p>" | |
| convert_btn.click( | |
| fn=convert_with_preview, | |
| inputs=[vox_input], | |
| outputs=[glb_output, status_output, preview_html] | |
| ) | |
| gr.Markdown(""" | |
| ### ๐ How to Use | |
| 1. Upload your `.vox` file | |
| 2. Click "Convert to GLB" | |
| 3. Download the GLB file | |
| 4. Preview your voxel model in 3D above | |
| """) | |
| return app | |
| if __name__ == "__main__": | |
| app = create_gradio_interface() | |
| app.launch(server_name="0.0.0.0", server_port=7860, share=True, show_error=True) |