| <!DOCTYPE html> |
| <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/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> |
| |
| <script> |
| |
| 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; |
| |
| 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(); |
| |
| |
| let baseGeometry; |
| if (renderMode === 'ball') { |
| baseGeometry = new THREE.SphereGeometry(voxelSize * 0.5, ballSegments, ballSegments); |
| } else { |
| baseGeometry = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize); |
| } |
| |
| |
| const materials = []; |
| const colorMap = new Map(); |
| |
| |
| 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); |
| } |
| |
| |
| const meshGroups = new Map(); |
| |
| |
| const offsetX = voxData.size.x / 2; |
| const offsetY = voxData.size.y / 2; |
| const offsetZ = voxData.size.z / 2; |
| |
| |
| for (let i = 0; i < voxels.length; i++) { |
| const voxel = voxels[i]; |
| const colorIndex = voxel.colorIndex - 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 |
| }); |
| } |
| } |
| |
| |
| const voxelGroup = new THREE.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> |
| |
| 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); |
| |
| |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.05; |
| |
| |
| const defaultCameraPosition = new THREE.Vector3(8, 8, 12); |
| const defaultCameraLookAt = new THREE.Vector3(0, 0, 0); |
| |
| |
| 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); |
| |
| |
| let currentModel = null; |
| let currentVOXData = null; |
| let currentFileType = 'none'; |
| let autoRotateEnabled = true; |
| let currentRenderMode = 'box'; |
| let currentColorAmplify = 1.0; |
| let modelCenter = new THREE.Vector3(0, 0, 0); |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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); |
| |
| |
| const box = new THREE.Box3().setFromObject(model); |
| const center = box.getCenter(new THREE.Vector3()); |
| const size = box.getSize(new THREE.Vector3()); |
| |
| |
| 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); |
| |
| |
| 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; |
| |
| |
| document.getElementById('boxMode').classList.toggle('active', mode === 'box'); |
| document.getElementById('ballMode').classList.toggle('active', mode === 'ball'); |
| |
| |
| const ballSegmentsControl = document.getElementById('ballSegmentsControl'); |
| ballSegmentsControl.style.display = mode === 'ball' ? 'block' : 'none'; |
| |
| |
| 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; |
| |
| |
| controls.target.copy(modelCenter); |
| controls.update(); |
| |
| |
| camera.lookAt(modelCenter); |
| } |
| |
| function resetToDefaultPosition() { |
| |
| camera.position.copy(defaultCameraPosition); |
| camera.lookAt(defaultCameraLookAt); |
| |
| |
| 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); |
| |
| |
| 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(); |
| } |
| |
| |
| 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); |
| |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| |
| controls.update(); |
| |
| if (currentModel && autoRotateEnabled) { |
| currentModel.rotation.y += parseFloat(document.getElementById('rotationSpeed').value); |
| } |
| |
| renderer.render(scene, camera); |
| } |
| |
| |
| window.addEventListener('resize', () => { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| }); |
| |
| |
| updateVoxelSize(); |
| updateBallSegments(); |
| updateColorAmplify(); |
| updateModelInfo('Ready to load models', 'none'); |
| |
| animate(); |
| </script> |
| </body> |
| </html> |