Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VOX Color Fix - Amplified Viewer</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| font-family: Arial, sans-serif; | |
| background: #1a1a1a; | |
| } | |
| #container { | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| color: white; | |
| background: rgba(0,0,0,0.8); | |
| padding: 15px; | |
| border-radius: 8px; | |
| max-width: 400px; | |
| } | |
| #controls h3 { | |
| margin-top: 0; | |
| color: #4CAF50; | |
| } | |
| .control-group { | |
| margin: 12px 0; | |
| } | |
| label { | |
| display: inline-block; | |
| width: 160px; | |
| font-size: 12px; | |
| } | |
| input[type="file"] { | |
| display: none; | |
| } | |
| .btn { | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| padding: 8px 12px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 11px; | |
| margin: 2px; | |
| } | |
| .btn:hover { | |
| background: #45a049; | |
| } | |
| .btn.active { | |
| background: #2196F3; | |
| } | |
| .btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| } | |
| #info { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| color: white; | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px; | |
| border-radius: 5px; | |
| font-size: 12px; | |
| } | |
| #loading { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 18px; | |
| display: none; | |
| } | |
| .model-info { | |
| font-size: 11px; | |
| color: #ccc; | |
| margin-top: 8px; | |
| min-height: 30px; | |
| } | |
| .render-mode { | |
| display: flex; | |
| gap: 5px; | |
| margin: 8px 0; | |
| } | |
| .render-mode button { | |
| flex: 1; | |
| } | |
| .file-types { | |
| font-size: 10px; | |
| color: #aaa; | |
| margin-top: 4px; | |
| } | |
| .status-indicator { | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| margin-right: 6px; | |
| } | |
| .status-indicator.glb { background: #FF9800; } | |
| .status-indicator.vox { background: #4CAF50; } | |
| .status-indicator.none { background: #666; } | |
| .rotation-controls { | |
| background: rgba(255,255,255,0.1); | |
| padding: 8px; | |
| border-radius: 4px; | |
| margin: 8px 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"></div> | |
| <div id="controls"> | |
| <h3>VOX Color Amplifier</h3> | |
| <div class="control-group"> | |
| <button class="btn" onclick="document.getElementById('glbFileInput').click()">Load GLB/GLTF</button> | |
| <input type="file" id="glbFileInput" accept=".glb,.gltf"> | |
| <div class="file-types">GLB, GLTF files</div> | |
| </div> | |
| <div class="control-group"> | |
| <button class="btn" onclick="document.getElementById('voxFileInput').click()">Load VOX File</button> | |
| <input type="file" id="voxFileInput" accept=".vox"> | |
| <div class="file-types">MagicaVoxel files</div> | |
| </div> | |
| <div class="control-group" id="voxControls" style="display: none;"> | |
| <label>VOX Render Mode:</label> | |
| <div class="render-mode"> | |
| <button class="btn active" id="boxMode" onclick="setRenderMode('box')">Boxes</button> | |
| <button class="btn" id="ballMode" onclick="setRenderMode('ball')">Balls</button> | |
| </div> | |
| </div> | |
| <div class="control-group" id="voxelSizeControl" style="display: none;"> | |
| <label>Voxel Size:</label> | |
| <input type="range" id="voxelSize" min="0.3" max="1.5" step="0.1" value="0.8"> | |
| <span id="voxelSizeValue">0.8</span> | |
| </div> | |
| <div class="control-group" id="ballSegmentsControl" style="display: none;"> | |
| <label>Ball Segments:</label> | |
| <input type="range" id="ballSegments" min="4" max="16" step="1" value="8"> | |
| <span id="ballSegmentsValue">8</span> | |
| </div> | |
| <div class="control-group" id="colorAmplifyControl" style="display: none;"> | |
| <label>Color Amplify:</label> | |
| <input type="range" id="colorAmplify" min="0.1" max="50" step="0.1" value="1.0"> | |
| <span id="colorAmplifyValue">1.0</span>x | |
| </div> | |
| <div class="rotation-controls"> | |
| <div class="control-group"> | |
| <label>Rotation Center:</label> | |
| <button class="btn" onclick="centerRotationOnModel()">Center on Model</button> | |
| </div> | |
| <div class="control-group"> | |
| <label>Default Position:</label> | |
| <button class="btn" onclick="resetToDefaultPosition()">Reset Position</button> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>Rotation Speed:</label> | |
| <input type="range" id="rotationSpeed" min="0" max="0.02" step="0.001" value="0.005"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Auto Rotate:</label> | |
| <input type="checkbox" id="autoRotate" checked> | |
| </div> | |
| <div class="control-group"> | |
| <button class="btn" onclick="resetView()">Reset View</button> | |
| <button class="btn" onclick="loadSampleModel('chest')">Load Sample Chest</button> | |
| </div> | |
| <div class="model-info"> | |
| <span class="status-indicator" id="statusIndicator"></span> | |
| <span id="modelInfo">No model loaded</span> | |
| </div> | |
| </div> | |
| <div id="loading">Loading...</div> | |
| <div id="info">Click and drag to rotate • Scroll to zoom • Use color amplifier for VOX files!</div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/GLTFLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| // Fixed VOX parser with proper color handling and amplification | |
| class VOXParser { | |
| static parse(buffer) { | |
| const view = new DataView(buffer); | |
| let offset = 0; | |
| const header = new TextDecoder().decode(buffer.slice(0, 4)); | |
| if (header !== 'VOX ') { | |
| throw new Error('Invalid VOX file'); | |
| } | |
| offset = 8; // Skip version | |
| const chunks = {}; | |
| while (offset < buffer.byteLength) { | |
| const chunkId = new TextDecoder().decode(buffer.slice(offset, offset + 4)); | |
| const chunkSize = view.getUint32(offset + 4, true); | |
| const childSize = view.getUint32(offset + 8, true); | |
| offset += 12; | |
| if (chunkId === 'SIZE') { | |
| const sizeX = view.getUint32(offset, true); | |
| const sizeY = view.getUint32(offset + 4, true); | |
| const sizeZ = view.getUint32(offset + 8, true); | |
| chunks.size = { x: sizeX, y: sizeY, z: sizeZ }; | |
| } else if (chunkId === 'XYZI') { | |
| const numVoxels = view.getUint32(offset, true); | |
| const voxels = []; | |
| for (let i = 0; i < numVoxels; i++) { | |
| const x = view.getUint8(offset + 4 + i * 4); | |
| const y = view.getUint8(offset + 4 + i * 4 + 1); | |
| const z = view.getUint8(offset + 4 + i * 4 + 2); | |
| const colorIndex = view.getUint8(offset + 4 + i * 4 + 3); | |
| voxels.push({ x, y, z, colorIndex }); | |
| } | |
| chunks.voxels = voxels; | |
| } else if (chunkId === 'RGBA') { | |
| const colors = []; | |
| for (let i = 0; i < 256; i++) { | |
| const r = view.getUint8(offset + i * 4); | |
| const g = view.getUint8(offset + i * 4 + 1); | |
| const b = view.getUint8(offset + i * 4 + 2); | |
| const a = view.getUint8(offset + i * 4 + 3); | |
| colors.push({ r, g, b, a }); | |
| } | |
| chunks.palette = colors; | |
| } | |
| offset += chunkSize; | |
| } | |
| return chunks; | |
| } | |
| static createInstancedGeometry(voxData, renderMode = 'box', voxelSize = 0.8, ballSegments = 8, colorAmplify = 1.0) { | |
| if (!voxData.voxels || !voxData.size) return null; | |
| const voxels = voxData.voxels; | |
| const palette = voxData.palette || this.generateDefaultPalette(); | |
| // Create base geometry based on render mode | |
| let baseGeometry; | |
| if (renderMode === 'ball') { | |
| baseGeometry = new THREE.SphereGeometry(voxelSize * 0.5, ballSegments, ballSegments); | |
| } else { | |
| baseGeometry = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize); | |
| } | |
| // Create materials for different colors instead of using vertex colors | |
| const materials = []; | |
| const colorMap = new Map(); | |
| // Create materials for each unique color in the palette | |
| for (let i = 0; i < palette.length; i++) { | |
| const color = palette[i]; | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: new THREE.Color( | |
| Math.min(1.0, (color.r / 255) * colorAmplify), | |
| Math.min(1.0, (color.g / 255) * colorAmplify), | |
| Math.min(1.0, (color.b / 255) * colorAmplify) | |
| ), | |
| shininess: 30 | |
| }); | |
| materials.push(material); | |
| colorMap.set(i, materials.length - 1); | |
| } | |
| // Create groups of meshes by material for better performance | |
| const meshGroups = new Map(); | |
| // Calculate offsets to center the model | |
| const offsetX = voxData.size.x / 2; | |
| const offsetY = voxData.size.y / 2; | |
| const offsetZ = voxData.size.z / 2; | |
| // Group voxels by color | |
| for (let i = 0; i < voxels.length; i++) { | |
| const voxel = voxels[i]; | |
| const colorIndex = voxel.colorIndex - 1; // VOX colors start at 1 | |
| if (colorIndex >= 0 && colorIndex < palette.length) { | |
| const materialIndex = colorIndex; | |
| if (!meshGroups.has(materialIndex)) { | |
| meshGroups.set(materialIndex, []); | |
| } | |
| meshGroups.get(materialIndex).push({ | |
| x: voxel.x - offsetX, | |
| y: voxel.y - offsetY, | |
| z: voxel.z - offsetZ | |
| }); | |
| } | |
| } | |
| // Create a group to hold all the voxel meshes | |
| const voxelGroup = new THREE.Group(); | |
| // Create instanced meshes for each color group | |
| for (const [materialIndex, positions] of meshGroups) { | |
| if (positions.length === 0) continue; | |
| const instancedMesh = new THREE.InstancedMesh( | |
| baseGeometry, | |
| materials[materialIndex], | |
| positions.length | |
| ); | |
| const dummy = new THREE.Object3D(); | |
| for (let i = 0; i < positions.length; i++) { | |
| const pos = positions[i]; | |
| dummy.position.set(pos.x, pos.y, pos.z); | |
| dummy.updateMatrix(); | |
| instancedMesh.setMatrixAt(i, dummy.matrix); | |
| } | |
| instancedMesh.castShadow = true; | |
| instancedMesh.receiveShadow = true; | |
| voxelGroup.add(instancedMesh); | |
| } | |
| return voxelGroup; | |
| } | |
| static generateDefaultPalette() { | |
| const palette = []; | |
| for (let i = 0; i < 256; i++) { | |
| palette.push({ | |
| r: Math.random() * 255, | |
| g: Math.random() * 255, | |
| b: Math.random() * 255, | |
| a: 255 | |
| }); | |
| } | |
| return palette; | |
| } | |
| } | |
| </script> | |
| <script> | |
| // Scene setup | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x2a2a2a); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(8, 8, 12); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| document.getElementById('container').appendChild(renderer.domElement); | |
| // Controls | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| // Store original camera position for reset | |
| const defaultCameraPosition = new THREE.Vector3(8, 8, 12); | |
| const defaultCameraLookAt = new THREE.Vector3(0, 0, 0); | |
| // Lighting setup - make it brighter for better color visibility | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); | |
| directionalLight.position.set(10, 10, 5); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 2048; | |
| directionalLight.shadow.mapSize.height = 2048; | |
| scene.add(directionalLight); | |
| // Current state | |
| let currentModel = null; | |
| let currentVOXData = null; | |
| let currentFileType = 'none'; // 'none', 'glb', 'vox' | |
| let autoRotateEnabled = true; | |
| let currentRenderMode = 'box'; | |
| let currentColorAmplify = 1.0; | |
| let modelCenter = new THREE.Vector3(0, 0, 0); // Store model center for rotation | |
| // File input handlers | |
| document.getElementById('glbFileInput').addEventListener('change', handleGLBLoad); | |
| document.getElementById('voxFileInput').addEventListener('change', handleVOXLoad); | |
| function handleGLBLoad(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| showLoading(true); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const loader = new THREE.GLTFLoader(); | |
| loader.parse(e.target.result, '', function(gltf) { | |
| const model = gltf.scene; | |
| // Calculate model center | |
| const box = new THREE.Box3().setFromObject(model); | |
| modelCenter = box.getCenter(new THREE.Vector3()); | |
| loadModel(model, 'glb'); | |
| showVOXControls(false); | |
| showLoading(false); | |
| updateModelInfo(`GLB/GLTF: ${file.name}`, 'glb'); | |
| }, function(error) { | |
| console.error('Error loading GLTF:', error); | |
| showLoading(false); | |
| }); | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } | |
| function handleVOXLoad(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| showLoading(true); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| try { | |
| const voxData = VOXParser.parse(e.target.result); | |
| currentVOXData = voxData; | |
| // Calculate model center | |
| modelCenter.set( | |
| voxData.size.x / 2 - voxData.size.x / 2, | |
| voxData.size.y / 2 - voxData.size.y / 2, | |
| voxData.size.z / 2 - voxData.size.z / 2 | |
| ); | |
| const voxelGroup = VOXParser.createInstancedGeometry( | |
| voxData, | |
| currentRenderMode, | |
| parseFloat(document.getElementById('voxelSize').value), | |
| parseInt(document.getElementById('ballSegments').value), | |
| currentColorAmplify | |
| ); | |
| if (voxelGroup) { | |
| loadModel(voxelGroup, 'vox'); | |
| showVOXControls(true); | |
| updateModelInfo(`VOX: ${file.name} (${voxData.voxels.length} voxels)`, 'vox'); | |
| } | |
| } catch (error) { | |
| console.error('Error loading VOX:', error); | |
| } | |
| showLoading(false); | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } | |
| function loadModel(model, fileType) { | |
| if (currentModel) { | |
| scene.remove(currentModel); | |
| if (currentModel.dispose) currentModel.dispose(); | |
| } | |
| currentModel = model; | |
| currentFileType = fileType; | |
| scene.add(model); | |
| // Center and scale model | |
| const box = new THREE.Box3().setFromObject(model); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const size = box.getSize(new THREE.Vector3()); | |
| // Update model center for rotation | |
| modelCenter.copy(center); | |
| model.position.sub(center); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 6 / maxDim; | |
| model.scale.multiplyScalar(scale); | |
| // Enable shadows | |
| model.traverse(function(child) { | |
| if (child.isMesh) { | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| } | |
| function showVOXControls(show) { | |
| const voxControls = document.getElementById('voxControls'); | |
| const voxelSizeControl = document.getElementById('voxelSizeControl'); | |
| const ballSegmentsControl = document.getElementById('ballSegmentsControl'); | |
| const colorAmplifyControl = document.getElementById('colorAmplifyControl'); | |
| voxControls.style.display = show ? 'block' : 'none'; | |
| voxelSizeControl.style.display = show ? 'block' : 'none'; | |
| ballSegmentsControl.style.display = show && currentRenderMode === 'ball' ? 'block' : 'none'; | |
| colorAmplifyControl.style.display = show ? 'block' : 'none'; | |
| } | |
| function setRenderMode(mode) { | |
| currentRenderMode = mode; | |
| // Update button states | |
| document.getElementById('boxMode').classList.toggle('active', mode === 'box'); | |
| document.getElementById('ballMode').classList.toggle('active', mode === 'ball'); | |
| // Show/hide ball segments control | |
| const ballSegmentsControl = document.getElementById('ballSegmentsControl'); | |
| ballSegmentsControl.style.display = mode === 'ball' ? 'block' : 'none'; | |
| // Rebuild geometry if we have VOX data | |
| if (currentVOXData && currentFileType === 'vox') { | |
| const voxelGroup = VOXParser.createInstancedGeometry( | |
| currentVOXData, | |
| mode, | |
| parseFloat(document.getElementById('voxelSize').value), | |
| parseInt(document.getElementById('ballSegments').value), | |
| currentColorAmplify | |
| ); | |
| if (voxelGroup) { | |
| loadModel(voxelGroup, 'vox'); | |
| } | |
| } | |
| } | |
| function updateVoxelSize() { | |
| const value = parseFloat(document.getElementById('voxelSize').value); | |
| document.getElementById('voxelSizeValue').textContent = value.toFixed(1); | |
| if (currentVOXData && currentFileType === 'vox') { | |
| setRenderMode(currentRenderMode); | |
| } | |
| } | |
| function updateBallSegments() { | |
| const value = parseInt(document.getElementById('ballSegments').value); | |
| document.getElementById('ballSegmentsValue').textContent = value; | |
| if (currentVOXData && currentFileType === 'vox' && currentRenderMode === 'ball') { | |
| setRenderMode('ball'); | |
| } | |
| } | |
| function updateColorAmplify() { | |
| const value = parseFloat(document.getElementById('colorAmplify').value); | |
| document.getElementById('colorAmplifyValue').textContent = value.toFixed(1); | |
| currentColorAmplify = value; | |
| if (currentVOXData && currentFileType === 'vox') { | |
| setRenderMode(currentRenderMode); | |
| } | |
| } | |
| function centerRotationOnModel() { | |
| if (!currentModel) return; | |
| // Move controls target to model center | |
| controls.target.copy(modelCenter); | |
| controls.update(); | |
| // Optional: Move camera to look at the center | |
| camera.lookAt(modelCenter); | |
| } | |
| function resetToDefaultPosition() { | |
| // Reset camera to default position | |
| camera.position.copy(defaultCameraPosition); | |
| camera.lookAt(defaultCameraLookAt); | |
| // Reset controls target to origin | |
| controls.target.copy(defaultCameraLookAt); | |
| controls.update(); | |
| } | |
| function loadSampleModel(type) { | |
| if (type === 'chest') { | |
| const chestGroup = new THREE.Group(); | |
| const woodMaterial = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); | |
| const metalMaterial = new THREE.MeshPhongMaterial({ color: 0x444444 }); | |
| const baseGeometry = new THREE.BoxGeometry(2, 1, 1.2); | |
| const base = new THREE.Mesh(baseGeometry, woodMaterial); | |
| base.position.y = -0.3; | |
| base.castShadow = true; | |
| chestGroup.add(base); | |
| const lidGeometry = new THREE.BoxGeometry(2, 0.8, 1.2); | |
| const lid = new THREE.Mesh(lidGeometry, woodMaterial); | |
| lid.position.y = 0.2; | |
| lid.castShadow = true; | |
| chestGroup.add(lid); | |
| const bandGeometry = new THREE.BoxGeometry(2.05, 0.08, 1.25); | |
| const band = new THREE.Mesh(bandGeometry, metalMaterial); | |
| band.position.y = -0.1; | |
| chestGroup.add(band); | |
| // Calculate center | |
| const box = new THREE.Box3().setFromObject(chestGroup); | |
| modelCenter = box.getCenter(new THREE.Vector3()); | |
| loadModel(chestGroup, 'glb'); | |
| showVOXControls(false); | |
| updateModelInfo('Sample Chest Model (GLB)', 'glb'); | |
| } | |
| } | |
| function showLoading(show) { | |
| document.getElementById('loading').style.display = show ? 'block' : 'none'; | |
| } | |
| function updateModelInfo(info, fileType) { | |
| const statusIndicator = document.getElementById('statusIndicator'); | |
| const modelInfo = document.getElementById('modelInfo'); | |
| statusIndicator.className = `status-indicator ${fileType || 'none'}`; | |
| modelInfo.textContent = info; | |
| } | |
| function resetView() { | |
| resetToDefaultPosition(); | |
| } | |
| // Control event listeners | |
| document.getElementById('autoRotate').addEventListener('change', function(e) { | |
| autoRotateEnabled = e.target.checked; | |
| }); | |
| document.getElementById('voxelSize').addEventListener('input', updateVoxelSize); | |
| document.getElementById('ballSegments').addEventListener('input', updateBallSegments); | |
| document.getElementById('colorAmplify').addEventListener('input', updateColorAmplify); | |
| // Animation loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| if (currentModel && autoRotateEnabled) { | |
| currentModel.rotation.y += parseFloat(document.getElementById('rotationSpeed').value); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // Handle window resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // Initialize | |
| updateVoxelSize(); | |
| updateBallSegments(); | |
| updateColorAmplify(); | |
| updateModelInfo('Ready to load models', 'none'); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |