Last active
October 11, 2025 03:34
-
-
Save lardratboy/763e57f9fe5e04a1f5136845b79c369d to your computer and use it in GitHub Desktop.
B2Ply fork with transforms
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
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>B2Ply v3 - Bytes To Points (with Hilbert & Morton)</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #dropZone { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1000; | |
| display: none; | |
| background: rgba(0, 0, 0, 0.7); | |
| color: white; | |
| justify-content: center; | |
| align-items: center; | |
| font-family: Arial, sans-serif; | |
| font-size: 24px; | |
| } | |
| #instructions { | |
| position: fixed; | |
| bottom: 80px; | |
| left: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 10px; | |
| border-radius: 5px; | |
| } | |
| #objectList { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 15px; | |
| border-radius: 5px; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| } | |
| .object-item { | |
| margin: 5px 0; | |
| padding: 5px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 3px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .delete-btn { | |
| background: #ff4444; | |
| border: none; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| .delete-btn:hover { | |
| background: #ff6666; | |
| } | |
| .visibility-btn { | |
| background: none; | |
| border: none; | |
| color: white; | |
| cursor: pointer; | |
| padding: 2px 6px; | |
| } | |
| .export-btn { | |
| background: #44aaff; | |
| border: none; | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 10px; | |
| } | |
| .export-btn:hover { | |
| background: #66bbff; | |
| } | |
| .export-btn.bin { | |
| background: #aa44ff; | |
| } | |
| .export-btn.bin:hover { | |
| background: #bb66ff; | |
| } | |
| .object-info { | |
| flex-grow: 1; | |
| } | |
| .export-buttons { | |
| display: flex; | |
| gap: 2px; | |
| } | |
| #gridControls { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 15px; | |
| border-radius: 5px; | |
| } | |
| .grid-toggle { | |
| margin: 5px 0; | |
| cursor: pointer; | |
| } | |
| #fileInput { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| } | |
| #exportButtons { | |
| position: fixed; | |
| bottom: 80px; | |
| right: 20px; | |
| display: flex; | |
| gap: 10px; | |
| z-index: 100; | |
| } | |
| .export-all-btn { | |
| background: #44cc44; | |
| border: none; | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 5px; | |
| font-family: Arial, sans-serif; | |
| cursor: pointer; | |
| } | |
| .export-all-btn:hover { | |
| background: #66dd66; | |
| } | |
| .export-all-btn.bin { | |
| background: #cc44cc; | |
| } | |
| .export-all-btn.bin:hover { | |
| background: #dd66dd; | |
| } | |
| /* Thumbnail styles */ | |
| .thumbnail { | |
| width: 50px; | |
| height: 50px; | |
| background-color: #333; | |
| border: 1px solid #555; | |
| cursor: pointer; | |
| object-fit: contain; | |
| } | |
| /* Image popup styles */ | |
| #imagePopup { | |
| display: none; | |
| position: fixed; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0,0,0,0.8); | |
| z-index: 2000; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| } | |
| #popupImage { | |
| max-width: 90%; | |
| max-height: 80%; | |
| border: 2px solid white; | |
| background-color: #222; | |
| } | |
| #popupCaption { | |
| color: white; | |
| margin-top: 10px; | |
| font-family: Arial, sans-serif; | |
| } | |
| #closePopup { | |
| position: absolute; | |
| top: 20px; | |
| right: 30px; | |
| font-size: 30px; | |
| color: white; | |
| cursor: pointer; | |
| } | |
| /* Projection controls */ | |
| #projectionControls { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| padding: 15px; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| justify-content: center; | |
| } | |
| #blendSlider { | |
| width: 300px; | |
| height: 6px; | |
| background: #444; | |
| outline: none; | |
| border-radius: 3px; | |
| } | |
| #blendSlider::-webkit-slider-thumb { | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: #44aaff; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| #blendSlider::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| background: #44aaff; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| border: none; | |
| } | |
| .control-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .control-label { | |
| font-size: 14px; | |
| min-width: 80px; | |
| } | |
| #quantizationSelect, #projectionSelect { | |
| background: #333; | |
| color: white; | |
| border: 1px solid #555; | |
| padding: 4px 8px; | |
| border-radius: 3px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="dropZone">Drop data or images here</div> | |
| <div id="instructions"> | |
| Drag and drop files/images to view bytes/rgb as points<br> | |
| Mouse: Left click + drag to rotate<br> | |
| Mouse wheel to zoom | |
| </div> | |
| <input type="file" id="fileInput" multiple /> | |
| <div id="gridControls"> | |
| <label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xy')"> XY Plane | |
| (Blue)</label><br> | |
| <label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xz')"> XZ Plane | |
| (Red)</label><br> | |
| <label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('yz')"> YZ Plane (Green)</label> | |
| </div> | |
| <div id="objectList"> | |
| <h3 style="margin-top: 0">Loaded Objects</h3> | |
| <div id="objectItems"></div> | |
| </div> | |
| <div id="exportButtons"> | |
| <button id="exportAllPlyBtn" class="export-all-btn">Export All as PLY</button> | |
| <button id="exportAllBinBtn" class="export-all-btn bin">Export All as BIN</button> | |
| </div> | |
| <!-- Projection controls --> | |
| <div id="projectionControls"> | |
| <div class="control-group"> | |
| <label class="control-label">Projection:</label> | |
| <select id="projectionSelect"> | |
| <option value="tiled">Quantized Tiled</option> | |
| <option value="hilbert">Hilbert Curve</option> | |
| <option value="morton">Morton Order (Z-order)</option> | |
| <option value="stereoA">Stereographic A</option> | |
| <option value="stereoB">Stereographic B</option> | |
| <option value="fibonacci">Fibonacci Sphere</option> | |
| </select> | |
| </div> | |
| <div class="control-group" id="quantizationGroup"> | |
| <label class="control-label">Quantization:</label> | |
| <select id="quantizationSelect"> | |
| <option value="4">4-bit (16³)</option> | |
| <option value="5">5-bit (32³)</option> | |
| <option value="6">6-bit (64³)</option> | |
| <option value="7">7-bit (128³)</option> | |
| <option value="8" selected>8-bit (256³)</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label class="control-label">3D ↔ <span id="projectionLabel">Tiled</span>:</label> | |
| <input type="range" id="blendSlider" min="0" max="1" step="0.01" value="0"> | |
| <span id="blendValue">0%</span> | |
| </div> | |
| </div> | |
| <!-- Image popup container --> | |
| <div id="imagePopup"> | |
| <span id="closePopup">×</span> | |
| <img id="popupImage"> | |
| <div id="popupCaption"></div> | |
| </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/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| let scene, camera, renderer, controls; | |
| let pointClouds = new Map(); | |
| let objectCounter = 0; | |
| let grids = {}; | |
| let blendFactor = 0; | |
| let quantizationBits = 8; | |
| let currentProjection = 'tiled'; | |
| // Vertex shader for projection morphing | |
| const vertexShader = ` | |
| attribute vec3 originalPosition; | |
| attribute vec3 projectedPosition; | |
| uniform float blendFactor; | |
| uniform float pointSize; | |
| varying vec3 vColor; | |
| void main() { | |
| // Interpolate between original 3D position and projected position | |
| vec3 position = mix(originalPosition, projectedPosition, blendFactor); | |
| // Pass color to fragment shader (Three.js provides 'color' attribute automatically) | |
| vColor = color; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| gl_PointSize = pointSize; | |
| } | |
| `; | |
| // Fragment shader | |
| const fragmentShader = ` | |
| varying vec3 vColor; | |
| void main() { | |
| // Use the interpolated vertex color | |
| gl_FragColor = vec4(vColor, 1.0); | |
| } | |
| `; | |
| function init() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111111); | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(2, 2, 2); | |
| camera.lookAt(0, 0, 0); | |
| renderer = new THREE.WebGLRenderer({ antialias: false }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| setupGrids(); | |
| setupDropZone(); | |
| setupExportButtons(); | |
| setupImagePopup(); | |
| setupProjectionControls(); | |
| animate(); | |
| } | |
| function setupProjectionControls() { | |
| const blendSlider = document.getElementById('blendSlider'); | |
| const blendValue = document.getElementById('blendValue'); | |
| const quantizationSelect = document.getElementById('quantizationSelect'); | |
| const projectionSelect = document.getElementById('projectionSelect'); | |
| const projectionLabel = document.getElementById('projectionLabel'); | |
| const quantizationGroup = document.getElementById('quantizationGroup'); | |
| blendSlider.addEventListener('input', (e) => { | |
| blendFactor = parseFloat(e.target.value); | |
| blendValue.textContent = Math.round(blendFactor * 100) + '%'; | |
| updateAllShaderUniforms(); | |
| }); | |
| quantizationSelect.addEventListener('change', (e) => { | |
| quantizationBits = parseInt(e.target.value); | |
| if (currentProjection === 'tiled' || currentProjection === 'hilbert' || currentProjection === 'morton') { | |
| // Recalculate positions for projections that use quantization | |
| pointClouds.forEach((cloud, id) => { | |
| updateProjectedPositions(cloud); | |
| }); | |
| } | |
| }); | |
| projectionSelect.addEventListener('change', (e) => { | |
| const newProjection = e.target.value; | |
| if (newProjection !== currentProjection) { | |
| currentProjection = newProjection; | |
| // Update UI | |
| if (currentProjection === 'tiled') { | |
| projectionLabel.textContent = 'Tiled'; | |
| quantizationGroup.style.display = 'flex'; | |
| } else if (currentProjection === 'hilbert') { | |
| projectionLabel.textContent = 'Hilbert'; | |
| quantizationGroup.style.display = 'flex'; | |
| } else if (currentProjection === 'morton') { | |
| projectionLabel.textContent = 'Morton'; | |
| quantizationGroup.style.display = 'flex'; | |
| } else if (currentProjection === 'stereoA') { | |
| projectionLabel.textContent = 'Stereo A'; | |
| quantizationGroup.style.display = 'none'; | |
| } else if (currentProjection === 'stereoB') { | |
| projectionLabel.textContent = 'Stereo B'; | |
| quantizationGroup.style.display = 'none'; | |
| } else if (currentProjection === 'fibonacci') { | |
| projectionLabel.textContent = 'Fibonacci'; | |
| quantizationGroup.style.display = 'none'; | |
| } | |
| // Recalculate projected positions for all point clouds | |
| pointClouds.forEach((cloud, id) => { | |
| updateProjectedPositions(cloud); | |
| }); | |
| } | |
| }); | |
| } | |
| function updateAllShaderUniforms() { | |
| pointClouds.forEach(cloud => { | |
| if (cloud.object.material.uniforms) { | |
| cloud.object.material.uniforms.blendFactor.value = blendFactor; | |
| } | |
| }); | |
| } | |
| function calculateTiledProjection(points, quantizationBits) { | |
| const q = quantizationBits; | |
| const qRange = Math.pow(2, q); | |
| const sqrtQRange = Math.floor(Math.sqrt(qRange)); | |
| const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1); | |
| // Create new projected points array | |
| const projectedPoints = new Float32Array(points.length); | |
| for (let i = 0; i < points.length; i += 3) { | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| // Convert normalized coordinates [-1,1] to discrete grid [0, qRange-1] | |
| const discreteX = Math.max(0, Math.min(qRange - 1, Math.floor((x + 1) / 2 * qRange))); | |
| const discreteY = Math.max(0, Math.min(qRange - 1, Math.floor((y + 1) / 2 * qRange))); | |
| const discreteZ = Math.max(0, Math.min(qRange - 1, Math.floor((z + 1) / 2 * qRange))); | |
| // Apply tiling formula: (col, row) = (z % sqrt(2^q), floor(z / sqrt(2^q))) | |
| const col = discreteZ % sqrtQRange; | |
| const row = Math.floor(discreteZ / sqrtQRange); | |
| // Calculate tiled coordinates: col * 2^q + x, row * 2^q + y | |
| const tiledX = col * qRange + discreteX; | |
| const tiledY = row * qRange + discreteY; | |
| // Normalize back for display (scale to fit in reasonable viewing area) | |
| projectedPoints[i] = (tiledX / maxTiledCoord) * 2 - 1; | |
| projectedPoints[i + 1] = (tiledY / maxTiledCoord) * 2 - 1; | |
| projectedPoints[i + 2] = 0; // Flatten to z=0 plane | |
| } | |
| return projectedPoints; | |
| } | |
| // 3D Hilbert curve encoding - converts (x,y,z) coordinates to index along curve | |
| function xyz2hilbert(x, y, z, order) { | |
| let index = 0; | |
| let rx, ry, rz; | |
| for (let i = order - 1; i >= 0; i--) { | |
| const mask = 1 << i; | |
| rx = (x & mask) ? 1 : 0; | |
| ry = (y & mask) ? 1 : 0; | |
| rz = (z & mask) ? 1 : 0; | |
| // Convert 3-bit position to index (0-7) | |
| const position = (rx << 2) | (ry << 1) | rz; | |
| // Add contribution of this level (shift by 3 bits for octree) | |
| index = (index << 3) | position; | |
| // Apply rotation for next level | |
| const coords = rotateHilbertCoords(rx, ry, rz, position); | |
| if (coords.flipX) x ^= mask; | |
| if (coords.flipY) y ^= mask; | |
| if (coords.flipZ) z ^= mask; | |
| } | |
| return index; | |
| } | |
| // Rotation logic for 3D Hilbert curve to maintain space-filling property | |
| function rotateHilbertCoords(x, y, z, state) { | |
| // Simplified rotation table for 3D Hilbert curve | |
| // These flips help maintain locality through the curve | |
| const rotations = [ | |
| {flipX: false, flipY: false, flipZ: false}, // 000 | |
| {flipX: false, flipY: false, flipZ: true}, // 001 | |
| {flipX: false, flipY: true, flipZ: true}, // 010 | |
| {flipX: false, flipY: true, flipZ: false}, // 011 | |
| {flipX: true, flipY: true, flipZ: false}, // 100 | |
| {flipX: true, flipY: true, flipZ: true}, // 101 | |
| {flipX: true, flipY: false, flipZ: true}, // 110 | |
| {flipX: true, flipY: false, flipZ: false} // 111 | |
| ]; | |
| return rotations[state]; | |
| } | |
| function calculateHilbertProjection(points, quantizationBits) { | |
| const projectedPoints = new Float32Array(points.length); | |
| const q = quantizationBits; | |
| const qRange = Math.pow(2, q); | |
| const totalCells = Math.pow(qRange, 3); | |
| const width = Math.ceil(Math.sqrt(totalCells)); | |
| for (let i = 0; i < points.length; i += 3) { | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| // Convert normalized coordinates [-1,1] to discrete grid [0, qRange-1] | |
| const discreteX = Math.max(0, Math.min(qRange - 1, Math.floor((x + 1) / 2 * qRange))); | |
| const discreteY = Math.max(0, Math.min(qRange - 1, Math.floor((y + 1) / 2 * qRange))); | |
| const discreteZ = Math.max(0, Math.min(qRange - 1, Math.floor((z + 1) / 2 * qRange))); | |
| // Convert 3D position to Hilbert index | |
| const hilbertIndex = xyz2hilbert(discreteX, discreteY, discreteZ, q); | |
| // Convert 1D Hilbert index to 2D position (serpentine layout) | |
| const col = hilbertIndex % width; | |
| const row = Math.floor(hilbertIndex / width); | |
| // Normalize back to [-1, 1] range for display | |
| projectedPoints[i] = (col / width) * 2 - 1; | |
| projectedPoints[i + 1] = (row / width) * 2 - 1; | |
| projectedPoints[i + 2] = 0; // Flatten to z=0 plane | |
| } | |
| return projectedPoints; | |
| } | |
| // Morton order (Z-order curve) encoding - interleaves bits of x, y, z coordinates | |
| function xyz2morton(x, y, z) { | |
| let morton = 0; | |
| // Interleave bits: for each bit position, add z bit, then y bit, then x bit | |
| for (let i = 0; i < 32; i++) { | |
| morton |= ((x & (1 << i)) << (2 * i)) | | |
| ((y & (1 << i)) << (2 * i + 1)) | | |
| ((z & (1 << i)) << (2 * i + 2)); | |
| } | |
| return morton; | |
| } | |
| function calculateMortonProjection(points, quantizationBits) { | |
| const projectedPoints = new Float32Array(points.length); | |
| const q = quantizationBits; | |
| const qRange = Math.pow(2, q); | |
| const totalCells = Math.pow(qRange, 3); | |
| const width = Math.ceil(Math.sqrt(totalCells)); | |
| for (let i = 0; i < points.length; i += 3) { | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| // Convert normalized coordinates [-1,1] to discrete grid [0, qRange-1] | |
| const discreteX = Math.max(0, Math.min(qRange - 1, Math.floor((x + 1) / 2 * qRange))); | |
| const discreteY = Math.max(0, Math.min(qRange - 1, Math.floor((y + 1) / 2 * qRange))); | |
| const discreteZ = Math.max(0, Math.min(qRange - 1, Math.floor((z + 1) / 2 * qRange))); | |
| // Convert 3D position to Morton index | |
| const mortonIndex = xyz2morton(discreteX, discreteY, discreteZ); | |
| // Convert 1D Morton index to 2D position (serpentine layout) | |
| const col = mortonIndex % width; | |
| const row = Math.floor(mortonIndex / width); | |
| // Normalize back to [-1, 1] range for display | |
| projectedPoints[i] = (col / width) * 2 - 1; | |
| projectedPoints[i + 1] = (row / width) * 2 - 1; | |
| projectedPoints[i + 2] = 0; // Flatten to z=0 plane | |
| } | |
| return projectedPoints; | |
| } | |
| function calculateStereographicProjection(points, variant = 'A') { | |
| const projectedPoints = new Float32Array(points.length); | |
| for (let i = 0; i < points.length; i += 3) { | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| // First normalize the point to unit sphere | |
| const length = Math.sqrt(x * x + y * y + z * z); | |
| if (length === 0) { | |
| projectedPoints[i] = 0; | |
| projectedPoints[i + 1] = 0; | |
| projectedPoints[i + 2] = 0; | |
| continue; | |
| } | |
| const nx = x / length; | |
| const ny = y / length; | |
| const nz = z / length; | |
| let projX, projY, scale; | |
| if (variant === 'A') { | |
| // s2cA: p.xy / (0.5 - p.z) | |
| const denom = 0.5 - nz; | |
| if (Math.abs(denom) < 0.001) { | |
| projX = nx * 200; | |
| projY = ny * 200; | |
| } else { | |
| projX = nx / denom; | |
| projY = ny / denom; | |
| } | |
| scale = 0.08; // Adjusted for s2cA characteristics | |
| } else { | |
| // s2cB: p.xy / (1.0 - p.z) | |
| const denom = 1.0 - nz; | |
| if (Math.abs(denom) < 0.001) { | |
| projX = nx * 100; | |
| projY = ny * 100; | |
| } else { | |
| projX = nx / denom; | |
| projY = ny / denom; | |
| } | |
| scale = 0.15; // Adjusted for s2cB characteristics | |
| } | |
| projectedPoints[i] = projX * scale; | |
| projectedPoints[i + 1] = projY * scale; | |
| projectedPoints[i + 2] = 0; // Flatten to z=0 plane | |
| } | |
| return projectedPoints; | |
| } | |
| function calculateFibonacciSphere(points) { | |
| const projectedPoints = new Float32Array(points.length); | |
| const numPoints = points.length / 3; | |
| // Golden angle in radians | |
| const goldenAngle = Math.PI * (3.0 - Math.sqrt(5.0)); | |
| for (let i = 0; i < numPoints; i++) { | |
| const pointIndex = i * 3; | |
| // Fibonacci sphere algorithm | |
| const y = 1 - (i / (numPoints - 1)) * 2; // y goes from 1 to -1 | |
| const radius = Math.sqrt(1 - y * y); | |
| const theta = goldenAngle * i; | |
| const x = Math.cos(theta) * radius; | |
| const z = Math.sin(theta) * radius; | |
| projectedPoints[pointIndex] = x; | |
| projectedPoints[pointIndex + 1] = y; | |
| projectedPoints[pointIndex + 2] = z; | |
| } | |
| return projectedPoints; | |
| } | |
| function updateProjectedPositions(cloud) { | |
| const originalPositions = cloud.originalPositions; | |
| let projectedPositions; | |
| if (currentProjection === 'tiled') { | |
| projectedPositions = calculateTiledProjection(originalPositions, quantizationBits); | |
| } else if (currentProjection === 'hilbert') { | |
| projectedPositions = calculateHilbertProjection(originalPositions, quantizationBits); | |
| } else if (currentProjection === 'morton') { | |
| projectedPositions = calculateMortonProjection(originalPositions, quantizationBits); | |
| } else if (currentProjection === 'stereoA') { | |
| projectedPositions = calculateStereographicProjection(originalPositions, 'A'); | |
| } else if (currentProjection === 'stereoB') { | |
| projectedPositions = calculateStereographicProjection(originalPositions, 'B'); | |
| } else if (currentProjection === 'fibonacci') { | |
| projectedPositions = calculateFibonacciSphere(originalPositions); | |
| } | |
| cloud.object.geometry.setAttribute('projectedPosition', new THREE.Float32BufferAttribute(projectedPositions, 3)); | |
| } | |
| function setupGrids() { | |
| const gridSize = 2; // Reduced grid size to match normalized data | |
| const gridDivisions = 4; // More divisions for finer reference | |
| // XY Plane (Blue) | |
| const gridXY = new THREE.GridHelper(gridSize, gridDivisions, 0x0000ff, 0x0000ff); | |
| gridXY.rotation.x = Math.PI / 2; | |
| scene.add(gridXY); | |
| grids.xy = gridXY; | |
| // XZ Plane (Red) | |
| const gridXZ = new THREE.GridHelper(gridSize, gridDivisions, 0xff0000, 0xff0000); | |
| scene.add(gridXZ); | |
| grids.xz = gridXZ; | |
| // YZ Plane (Green) | |
| const gridYZ = new THREE.GridHelper(gridSize, gridDivisions, 0x00ff00, 0x00ff00); | |
| gridYZ.rotation.z = Math.PI / 2; | |
| scene.add(gridYZ); | |
| grids.yz = gridYZ; | |
| } | |
| function toggleGrid(plane) { | |
| if (grids[plane]) { | |
| grids[plane].visible = !grids[plane].visible; | |
| } | |
| } | |
| function removeAlphaChannel(canvas, data) { | |
| const imagePixelCount = canvas.width * canvas.height; | |
| const rgbOnlyBytes = new Uint8ClampedArray(imagePixelCount * 3); | |
| let nextImageIdx = 0; | |
| let nextRGBIdx = 0; | |
| for (let i = 0; i < imagePixelCount; i++) { | |
| rgbOnlyBytes[nextRGBIdx] = data[nextImageIdx]; | |
| rgbOnlyBytes[nextRGBIdx + 1] = data[nextImageIdx + 1]; | |
| rgbOnlyBytes[nextRGBIdx + 2] = data[nextImageIdx + 2]; | |
| nextImageIdx += 4; | |
| nextRGBIdx += 3; | |
| } | |
| return rgbOnlyBytes; | |
| } | |
| function handleFiles(files) { | |
| for (const file of files) { | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| if (file.type.startsWith('image/')) { | |
| const img = new Image(); | |
| img.onload = function () { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = img.width; | |
| canvas.height = img.height; | |
| ctx.drawImage(img, 0, 0); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| createPointCloud(removeAlphaChannel(canvas, imageData.data), file.name); | |
| }; | |
| img.src = e.target.result; | |
| } else { | |
| const arrayBuffer = e.target.result; | |
| const uint8Array = new Uint8Array(arrayBuffer); | |
| createPointCloud(uint8Array, file.name); | |
| } | |
| }; | |
| if (file.type.startsWith('image/')) { | |
| reader.readAsDataURL(file); | |
| } else { | |
| reader.readAsArrayBuffer(file); | |
| } | |
| } | |
| } | |
| // Handle clipboard operations | |
| document.addEventListener('paste', (e) => { | |
| e.preventDefault(); | |
| const items = (e.clipboardData || window.clipboardData).items; | |
| for (let item of items) { | |
| if (item.kind === 'file') { | |
| const file = item.getAsFile(); | |
| handleFiles([file]); | |
| } else { | |
| item.getAsString( (text) => { | |
| tryFetchAPI(text); | |
| } ); | |
| } | |
| } | |
| }); | |
| async function tryFetchAPI(src) { | |
| try { | |
| const response = await fetch(src); | |
| if (response.ok) { | |
| handleFiles([await response.blob()]); | |
| } | |
| } catch (_) { | |
| } | |
| } | |
| function handleNonFileDrop(text) { | |
| if (text.startsWith('<meta')) { | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(text, 'text/html'); | |
| const imgSrc = doc.querySelector('img').getAttribute('src'); | |
| tryFetchAPI(imgSrc); | |
| } catch (_) { | |
| } | |
| } | |
| } | |
| function handleDrop(e) { | |
| const dt = e.dataTransfer; | |
| const file = dt.files[0]; | |
| if (file) { | |
| handleFiles(e.dataTransfer.files); | |
| return; | |
| } | |
| for (let i = 0; i < dt.items.length; i++) { | |
| const item = dt.items[i]; | |
| if (!(item.kind === 'string')) continue; | |
| item.getAsString((s) => { | |
| handleNonFileDrop(s); | |
| }); | |
| } | |
| } | |
| function setupDropZone() { | |
| const dropZone = document.getElementById('dropZone'); | |
| document.addEventListener('dragenter', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'flex'; | |
| }); | |
| dropZone.addEventListener('dragleave', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'none'; | |
| }); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.style.display = 'none'; | |
| handleDrop(e); | |
| }); | |
| } | |
| function getUniqueTuples(data) { | |
| const bitArraySizeInUint32 = Math.ceil((256 * 256 * 256) / 32); | |
| const tupleBitArray = new Uint32Array(bitArraySizeInUint32); | |
| const resultTuples = []; | |
| for (let i = 0; i < data.length; i += 3) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const index = (r << 16) | (g << 8) | b; | |
| const elementIndex = index >> 5; | |
| const bitPosition = index & 0x1F; | |
| const mask = 1 << bitPosition; | |
| if ((tupleBitArray[elementIndex] & mask) === 0) { | |
| tupleBitArray[elementIndex] |= mask; | |
| resultTuples.push(r, g, b); | |
| } | |
| } | |
| return resultTuples; | |
| } | |
| function createThumbnailImage(rgbData) { | |
| // Calculate optimal dimensions for a square image | |
| const pointCount = rgbData.length / 3; | |
| const side = Math.ceil(Math.sqrt(pointCount)); | |
| // Create canvas and get context | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = side; | |
| canvas.height = side; | |
| const ctx = canvas.getContext('2d'); | |
| // Fill with black background | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(0, 0, side, side); | |
| // Create ImageData to draw the points | |
| const imageData = ctx.createImageData(side, side); | |
| const data = imageData.data; | |
| // Fill data with transparent black | |
| for (let i = 0; i < data.length; i += 4) { | |
| data[i] = 0; // R | |
| data[i+1] = 0; // G | |
| data[i+2] = 0; // B | |
| data[i+3] = 0; // A - transparent | |
| } | |
| // Add points to the image | |
| for (let i = 0; i < pointCount && i < side * side; i++) { | |
| const x = i % side; | |
| const y = Math.floor(i / side); | |
| const pixelIndex = (y * side + x) * 4; | |
| const pointIndex = i * 3; | |
| data[pixelIndex] = rgbData[pointIndex]; // R | |
| data[pixelIndex+1] = rgbData[pointIndex+1]; // G | |
| data[pixelIndex+2] = rgbData[pointIndex+2]; // B | |
| data[pixelIndex+3] = 255; // A - fully opaque | |
| } | |
| // Put the image data on the canvas | |
| ctx.putImageData(imageData, 0, 0); | |
| return { | |
| dataUrl: canvas.toDataURL('image/png'), | |
| width: side, | |
| height: side | |
| }; | |
| } | |
| function createPointCloud(buffer, fileName) { | |
| const rgbData = new Uint8Array(getUniqueTuples(buffer)); | |
| const pointCount = Math.floor(rgbData.length / 3); | |
| const vertices = new Float32Array(pointCount * 3); | |
| for (let i = 0; i < pointCount; i++) { | |
| const rgbIndex = i * 3; | |
| const vertexIndex = i * 3; | |
| // Convert RGB values to normalized coordinates (-1 to 1) | |
| const x = (rgbData[rgbIndex] / 255) * 2 - 1; | |
| const y = (rgbData[rgbIndex + 1] / 255) * 2 - 1; | |
| const z = (rgbData[rgbIndex + 2] / 255) * 2 - 1; | |
| vertices[vertexIndex] = x; | |
| vertices[vertexIndex + 1] = y; | |
| vertices[vertexIndex + 2] = z; | |
| } | |
| // Calculate projected positions based on current projection | |
| let projectedPositions; | |
| if (currentProjection === 'tiled') { | |
| projectedPositions = calculateTiledProjection(vertices, quantizationBits); | |
| } else if (currentProjection === 'hilbert') { | |
| projectedPositions = calculateHilbertProjection(vertices, quantizationBits); | |
| } else if (currentProjection === 'morton') { | |
| projectedPositions = calculateMortonProjection(vertices, quantizationBits); | |
| } else if (currentProjection === 'stereoA') { | |
| projectedPositions = calculateStereographicProjection(vertices, 'A'); | |
| } else if (currentProjection === 'stereoB') { | |
| projectedPositions = calculateStereographicProjection(vertices, 'B'); | |
| } else if (currentProjection === 'fibonacci') { | |
| projectedPositions = calculateFibonacciSphere(vertices); | |
| } | |
| // Create the 2D thumbnail image | |
| const thumbnail = createThumbnailImage(rgbData); | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); | |
| geometry.setAttribute('originalPosition', new THREE.Float32BufferAttribute(vertices, 3)); | |
| geometry.setAttribute('projectedPosition', new THREE.Float32BufferAttribute(projectedPositions, 3)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(rgbData, 3, true)); | |
| // Create shader material | |
| const material = new THREE.ShaderMaterial({ | |
| vertexShader: vertexShader, | |
| fragmentShader: fragmentShader, | |
| uniforms: { | |
| blendFactor: { value: blendFactor }, | |
| pointSize: { value: 0.005 * Math.max(window.innerWidth, window.innerHeight) / 100 } | |
| }, | |
| vertexColors: true | |
| }); | |
| const points = new THREE.Points(geometry, material); | |
| const id = `cloud_${objectCounter++}`; | |
| pointClouds.set(id, { | |
| object: points, | |
| name: fileName || 'unknown', | |
| pointCount: pointCount, | |
| thumbnail: thumbnail, | |
| originalPositions: vertices | |
| }); | |
| scene.add(points); | |
| updateObjectList(); | |
| } | |
| function setupImagePopup() { | |
| const popup = document.getElementById('imagePopup'); | |
| const closeBtn = document.getElementById('closePopup'); | |
| // Close popup when clicking the X | |
| closeBtn.addEventListener('click', () => { | |
| popup.style.display = 'none'; | |
| }); | |
| // Close popup when clicking outside the image | |
| popup.addEventListener('click', (e) => { | |
| if (e.target === popup) { | |
| popup.style.display = 'none'; | |
| } | |
| }); | |
| // Close popup with Escape key | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && popup.style.display === 'flex') { | |
| popup.style.display = 'none'; | |
| } | |
| }); | |
| } | |
| function showImagePopup(id) { | |
| const cloud = pointClouds.get(id); | |
| if (!cloud || !cloud.thumbnail) return; | |
| const popup = document.getElementById('imagePopup'); | |
| const img = document.getElementById('popupImage'); | |
| const caption = document.getElementById('popupCaption'); | |
| img.src = cloud.thumbnail.dataUrl; | |
| caption.textContent = `${cloud.name}: ${cloud.pointCount.toLocaleString()} unique points (${cloud.thumbnail.width}×${cloud.thumbnail.height})`; | |
| popup.style.display = 'flex'; | |
| } | |
| function updateObjectList() { | |
| const container = document.getElementById('objectItems'); | |
| container.innerHTML = ''; | |
| pointClouds.forEach((cloud, id) => { | |
| const item = document.createElement('div'); | |
| item.className = 'object-item'; | |
| // Create thumbnail if available | |
| if (cloud.thumbnail) { | |
| const thumb = document.createElement('img'); | |
| thumb.className = 'thumbnail'; | |
| thumb.src = cloud.thumbnail.dataUrl; | |
| thumb.title = 'Click to view full image'; | |
| thumb.onclick = () => showImagePopup(id); | |
| item.appendChild(thumb); | |
| } | |
| const info = document.createElement('div'); | |
| info.className = 'object-info'; | |
| info.textContent = `${cloud.name} (${cloud.pointCount.toLocaleString()} points)`; | |
| const visibilityBtn = document.createElement('button'); | |
| visibilityBtn.className = 'visibility-btn'; | |
| visibilityBtn.innerHTML = cloud.object.visible ? '👁️' : '👁️🗨️'; | |
| visibilityBtn.onclick = () => toggleVisibility(id); | |
| const exportButtons = document.createElement('div'); | |
| exportButtons.className = 'export-buttons'; | |
| const exportPlyBtn = document.createElement('button'); | |
| exportPlyBtn.className = 'export-btn'; | |
| exportPlyBtn.textContent = 'PLY'; | |
| exportPlyBtn.title = 'Export as PLY'; | |
| exportPlyBtn.onclick = () => exportAsPLY(id); | |
| const exportBinBtn = document.createElement('button'); | |
| exportBinBtn.className = 'export-btn bin'; | |
| exportBinBtn.textContent = 'BIN'; | |
| exportBinBtn.title = 'Export as binary (signed 8-bit)'; | |
| exportBinBtn.onclick = () => exportAsBIN(id); | |
| exportButtons.appendChild(exportPlyBtn); | |
| exportButtons.appendChild(exportBinBtn); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-btn'; | |
| deleteBtn.textContent = '×'; | |
| deleteBtn.onclick = () => deletePointCloud(id); | |
| item.appendChild(info); | |
| item.appendChild(visibilityBtn); | |
| item.appendChild(exportButtons); | |
| item.appendChild(deleteBtn); | |
| container.appendChild(item); | |
| }); | |
| } | |
| function toggleVisibility(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| cloud.object.visible = !cloud.object.visible; | |
| updateObjectList(); | |
| } | |
| } | |
| function deletePointCloud(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| scene.remove(cloud.object); | |
| pointClouds.delete(id); | |
| updateObjectList(); | |
| } | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (controls) controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| function setupExportButtons() { | |
| document.getElementById('exportAllPlyBtn').addEventListener('click', exportAllVisibleAsPLY); | |
| document.getElementById('exportAllBinBtn').addEventListener('click', exportAllVisibleAsBIN); | |
| } | |
| function exportAsPLY(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| const plyData = generatePLY(cloud.object); | |
| const filename = cloud.name.replace(/\.[^/.]+$/, "") + ".ply"; | |
| downloadText(plyData, filename, 'application/octet-stream'); | |
| } | |
| } | |
| function exportAsBIN(id) { | |
| const cloud = pointClouds.get(id); | |
| if (cloud) { | |
| const binData = generateBIN(cloud.object); | |
| const filename = cloud.name.replace(/\.[^/.]+$/, "") + ".bin"; | |
| downloadBlob(binData, filename, 'application/octet-stream'); | |
| } | |
| } | |
| function exportAllVisibleAsPLY() { | |
| // Count total points in all visible point clouds | |
| let totalPoints = 0; | |
| const visibleClouds = []; | |
| pointClouds.forEach(cloud => { | |
| if (cloud.object.visible) { | |
| totalPoints += cloud.pointCount; | |
| visibleClouds.push(cloud); | |
| } | |
| }); | |
| if (visibleClouds.length === 0) { | |
| console.log('No visible point clouds to export'); | |
| return; | |
| } | |
| // Generate combined PLY data | |
| const plyData = generateCombinedPLY(visibleClouds, totalPoints); | |
| downloadText(plyData, 'b2p_combined_export.ply', 'application/octet-stream'); | |
| } | |
| function exportAllVisibleAsBIN() { | |
| // Count total points in all visible point clouds | |
| let totalPoints = 0; | |
| const visibleClouds = []; | |
| pointClouds.forEach(cloud => { | |
| if (cloud.object.visible) { | |
| totalPoints += cloud.pointCount; | |
| visibleClouds.push(cloud); | |
| } | |
| }); | |
| if (visibleClouds.length === 0) { | |
| console.log('No visible point clouds to export'); | |
| return; | |
| } | |
| // Generate combined BIN data | |
| const binData = generateCombinedBIN(visibleClouds, totalPoints); | |
| downloadBlob(binData, 'b2p_combined_export.bin', 'application/octet-stream'); | |
| } | |
| function convertFloatToInt8(value) { | |
| // Convert from normalized float (-1 to 1) to signed 8-bit (-128 to 127) | |
| // Clamp the value to ensure it's within range | |
| const clamped = Math.max(-1, Math.min(1, value)); | |
| const scaled = clamped * 127; | |
| return Math.round(scaled); | |
| } | |
| function generateBIN(pointCloud) { | |
| const positions = pointCloud.geometry.getAttribute('position'); | |
| const pointCount = positions.count; | |
| // Create binary data array: 3 bytes per point (x, y, z as signed 8-bit) | |
| const binaryData = new Int8Array(pointCount * 3); | |
| for (let i = 0; i < pointCount; i++) { | |
| const x = positions.getX(i); | |
| const y = positions.getY(i); | |
| const z = positions.getZ(i); | |
| const baseIndex = i * 3; | |
| binaryData[baseIndex] = convertFloatToInt8(x); | |
| binaryData[baseIndex + 1] = convertFloatToInt8(y); | |
| binaryData[baseIndex + 2] = convertFloatToInt8(z); | |
| } | |
| return binaryData.buffer; | |
| } | |
| function generateCombinedBIN(clouds, totalPoints) { | |
| // Create binary data array for all visible clouds | |
| const binaryData = new Int8Array(totalPoints * 3); | |
| let currentIndex = 0; | |
| for (const cloud of clouds) { | |
| if (cloud.object.visible) { | |
| const positions = cloud.object.geometry.getAttribute('position'); | |
| for (let i = 0; i < positions.count; i++) { | |
| const x = positions.getX(i); | |
| const y = positions.getY(i); | |
| const z = positions.getZ(i); | |
| const baseIndex = currentIndex * 3; | |
| binaryData[baseIndex] = convertFloatToInt8(x); | |
| binaryData[baseIndex + 1] = convertFloatToInt8(y); | |
| binaryData[baseIndex + 2] = convertFloatToInt8(z); | |
| currentIndex++; | |
| } | |
| } | |
| } | |
| return binaryData.buffer; | |
| } | |
| function generatePLY(pointCloud) { | |
| const positions = pointCloud.geometry.getAttribute('position'); | |
| const colors = pointCloud.geometry.getAttribute('color'); | |
| const vertices = []; | |
| // Header | |
| let ply = 'ply\n'; | |
| ply += 'format ascii 1.0\n'; | |
| ply += `element vertex ${positions.count}\n`; | |
| ply += 'property float x\n'; | |
| ply += 'property float y\n'; | |
| ply += 'property float z\n'; | |
| ply += 'property uchar red\n'; | |
| ply += 'property uchar green\n'; | |
| ply += 'property uchar blue\n'; | |
| ply += 'end_header\n'; | |
| // Vertex data | |
| for (let i = 0; i < positions.count; i++) { | |
| const x = positions.getX(i); | |
| const y = positions.getY(i); | |
| const z = positions.getZ(i); | |
| const r = colors.getX(i); | |
| const g = colors.getY(i); | |
| const b = colors.getZ(i); | |
| ply += `${x} ${y} ${z} ${r} ${g} ${b}\n`; | |
| } | |
| return ply; | |
| } | |
| function generateCombinedPLY(clouds, totalPoints) { | |
| // Header | |
| let ply = 'ply\n'; | |
| ply += 'format ascii 1.0\n'; | |
| ply += `element vertex ${totalPoints}\n`; | |
| ply += 'property float x\n'; | |
| ply += 'property float y\n'; | |
| ply += 'property float z\n'; | |
| ply += 'property uchar red\n'; | |
| ply += 'property uchar green\n'; | |
| ply += 'property uchar blue\n'; | |
| ply += 'end_header\n'; | |
| // Add vertex data from all clouds | |
| for (const cloud of clouds) { | |
| if (cloud.object.visible) { | |
| const positions = cloud.object.geometry.getAttribute('position'); | |
| const colors = cloud.object.geometry.getAttribute('color'); | |
| for (let i = 0; i < positions.count; i++) { | |
| const x = positions.getX(i); | |
| const y = positions.getY(i); | |
| const z = positions.getZ(i); | |
| const r = colors.getX(i); | |
| const g = colors.getY(i); | |
| const b = colors.getZ(i); | |
| ply += `${x} ${y} ${z} ${r} ${g} ${b}\n`; | |
| } | |
| } | |
| } | |
| return ply; | |
| } | |
| function downloadText(text, filename, mimeType) { | |
| const blob = new Blob([text], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function downloadBlob(arrayBuffer, filename, mimeType) { | |
| const blob = new Blob([arrayBuffer], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| init(); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| // Update point size for all shaders based on new screen size | |
| pointClouds.forEach(cloud => { | |
| if (cloud.object.material.uniforms) { | |
| cloud.object.material.uniforms.pointSize.value = 0.005 * Math.max(window.innerWidth, window.innerHeight) / 100; | |
| } | |
| }); | |
| }); | |
| const argument = new URL(document.URL).searchParams.get('fetch'); | |
| if (argument) { | |
| console.log(argument); | |
| tryFetchAPI(argument); | |
| } | |
| // Handle file input | |
| document.getElementById('fileInput').addEventListener('change', function (e) { | |
| handleFiles(e.target.files); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment