Last active
May 14, 2025 02:54
-
-
Save lardratboy/4339907ae35281496f8675de7d9f802d to your computer and use it in GitHub Desktop.
HTML/javascript tool for viewing 8 to 32 bit data as point cloud
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 lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>3D Binary Point Cloud Viewer</title> | |
<style> | |
body { | |
margin: 0; | |
overflow: hidden; | |
font-family: Arial, sans-serif; | |
color: #fff; | |
background-color: #000; | |
} | |
#container { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
} | |
#controls { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
padding: 10px; | |
background-color: rgba(0, 0, 0, 0.7); | |
border-radius: 5px; | |
z-index: 100; | |
width: 300px; | |
} | |
#controls label, #controls select, #controls input, #controls button { | |
display: block; | |
margin: 5px 0; | |
width: 100%; | |
} | |
#controls button { | |
background-color: #2196F3; | |
color: white; | |
border: none; | |
padding: 8px; | |
border-radius: 3px; | |
cursor: pointer; | |
} | |
#controls button:hover { | |
background-color: #0b7dda; | |
} | |
#controls button:disabled { | |
background-color: #555; | |
cursor: not-allowed; | |
} | |
#exportButtons { | |
display: flex; | |
gap: 5px; | |
} | |
#exportButtons button { | |
flex: 1; | |
} | |
#fileInfo, #statsInfo { | |
margin-top: 10px; | |
font-size: 12px; | |
} | |
#loadingMessage { | |
display: none; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: rgba(0, 0, 0, 0.7); | |
padding: 20px; | |
border-radius: 5px; | |
z-index: 101; | |
} | |
#exportDialog { | |
display: none; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: rgba(0, 0, 0, 0.8); | |
padding: 20px; | |
border-radius: 5px; | |
z-index: 102; | |
width: 300px; | |
} | |
#exportDialog input { | |
width: 100%; | |
margin-bottom: 10px; | |
padding: 5px; | |
} | |
#exportDialog .buttons { | |
display: flex; | |
justify-content: space-between; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="container"></div> | |
<div id="controls"> | |
<h3>Binary Point Cloud Viewer</h3> | |
<input type="file" id="fileInput"> | |
<div id="fileInfo"></div> | |
<div id="statsInfo"></div> | |
<label for="dataType">Data Type:</label> | |
<select id="dataType"> | |
<option value="int8">Int8</option> | |
<option value="uint8">Uint8</option> | |
<option value="int16">Int16</option> | |
<option value="uint16">Uint16</option> | |
<option value="int32">Int32</option> | |
<option value="uint32">Uint32</option> | |
</select> | |
<label for="chunkSize">Chunk Size (MB):</label> | |
<input type="number" id="chunkSize" min="0.1" max="100" step="0.1" value="1"> | |
<label for="gridSize">Grid Size:</label> | |
<input type="number" id="gridSize" min="1" max="10" step="1" value="3"> | |
<label for="pointSize">Point Size:</label> | |
<input type="number" id="pointSize" min="0.005" max="1.5" step="0.01" value="0.005"> | |
<label for="endianness">Endianness:</label> | |
<select id="endianness"> | |
<option value="true">Little Endian</option> | |
<option value="false">Big Endian</option> | |
</select> | |
<button id="processButton" disabled>Process File</button> | |
<button id="resetButton">Reset View</button> | |
<div id="exportButtons"> | |
<button id="exportPlyButton" disabled>Export PLY</button> | |
<button id="exportGlbButton" disabled>Export GLB</button> | |
</div> | |
</div> | |
<div id="loadingMessage">Processing data... Please wait.</div> | |
<div id="exportDialog"> | |
<h3>Export File</h3> | |
<label for="exportFilename">Filename:</label> | |
<input type="text" id="exportFilename" placeholder="Enter filename"> | |
<div id="exportFormat"></div> | |
<div class="buttons"> | |
<button id="cancelExport">Cancel</button> | |
<button id="confirmExport">Export</button> | |
</div> | |
</div> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.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/exporters/GLTFExporter.js"></script> | |
<script> | |
// Data type configuration constants | |
const DATA_TYPES = { | |
int8: { size: 1, min: -128, max: 127, method: 'getInt8' }, | |
uint8: { size: 1, min: 0, max: 255, method: 'getUint8' }, | |
int16: { size: 2, min: -32768, max: 32767, method: 'getInt16' }, | |
uint16: { size: 2, min: 0, max: 65535, method: 'getUint16' }, | |
int32: { size: 4, min: -2147483648, max: 2147483647, method: 'getInt32' }, | |
uint32: { size: 4, min: 0, max: 4294967295, method: 'getUint32' } | |
}; | |
// Pre-calculate normalization multipliers for each data type | |
const NORMALIZERS = Object.fromEntries( | |
Object.entries(DATA_TYPES).map(([type, config]) => [ | |
type, | |
{ | |
multiplier: 2 / (config.max - config.min), | |
offset: config.min | |
} | |
]) | |
); | |
/** | |
* Process binary data into normalized 3D points with colors | |
* @param {ArrayBuffer} buffer - The input binary data | |
* @param {string} dataType - The data type to interpret the buffer as | |
* @param {boolean} isLittleEndian - Whether to read as little endian | |
* @returns {{ points: Float32Array, colors: Float32Array, numPoints: number }} | |
*/ | |
function processDataAs(buffer, dataType, isLittleEndian) { | |
const config = DATA_TYPES[dataType]; | |
if (!config) { | |
throw new Error(`Unsupported data type: ${dataType}`); | |
} | |
const view = new DataView(buffer); | |
const typeSize = config.size; | |
const tupleSize = typeSize * 3; | |
const maxOffset = buffer.byteLength - tupleSize; | |
const maxTuples = Math.floor(buffer.byteLength / tupleSize); | |
// Pre-allocate typed arrays for better performance | |
const points = new Float32Array(maxTuples * 3); | |
const colors = new Float32Array(maxTuples * 3); | |
// Cache normalization values and methods | |
const { multiplier, offset } = NORMALIZERS[dataType]; | |
const readMethod = view[config.method].bind(view); | |
const normalize = value => ((value - offset) * multiplier) - 1; | |
let pointIndex = 0; | |
let baseOffset = 0; | |
try { | |
while (baseOffset <= maxOffset) { | |
// Read and normalize all three coordinates | |
const x = normalize(readMethod(baseOffset, isLittleEndian)); | |
const y = normalize(readMethod(baseOffset + typeSize, isLittleEndian)); | |
const z = normalize(readMethod(baseOffset + typeSize * 2, isLittleEndian)); | |
// Store points | |
points[pointIndex] = x; | |
points[pointIndex + 1] = y; | |
points[pointIndex + 2] = z; | |
// Store colors | |
colors[pointIndex] = (x + 1) / 2; | |
colors[pointIndex + 1] = (y + 1) / 2; | |
colors[pointIndex + 2] = (z + 1) / 2; | |
pointIndex += 3; | |
baseOffset += tupleSize; | |
} | |
} catch (e) { | |
console.error(`Error processing data at offset: ${baseOffset}`, e); | |
} | |
return { | |
points, | |
colors, | |
numPoints: pointIndex / 3 | |
}; | |
} | |
// Main application | |
class BinaryPointCloudViewer { | |
constructor() { | |
this.fileBuffer = null; | |
this.scene = null; | |
this.camera = null; | |
this.renderer = null; | |
this.controls = null; | |
this.pointClouds = []; | |
this.originalFileName = ''; | |
this.cameraRadius = 5; | |
this.cameraAngle = 0; | |
this.exportType = ''; | |
this.totalPoints = 0; | |
this.init(); | |
this.setupEventListeners(); | |
this.animate(); | |
} | |
showExportDialog(type) { | |
this.exportType = type; | |
// Show export dialog | |
const dialog = document.getElementById('exportDialog'); | |
const filenameInput = document.getElementById('exportFilename'); | |
const formatDiv = document.getElementById('exportFormat'); | |
const cancelButton = document.getElementById('cancelExport'); | |
const confirmButton = document.getElementById('confirmExport'); | |
// Generate default filename from original file | |
const baseFilename = this.originalFileName.split('.')[0] || 'pointcloud'; | |
filenameInput.value = baseFilename + '_pointcloud'; | |
// Update dialog title and format info | |
document.querySelector('#exportDialog h3').textContent = `Export to ${type.toUpperCase()}`; | |
formatDiv.textContent = `Format: ${type.toUpperCase()}`; | |
// Show dialog | |
dialog.style.display = 'block'; | |
// Handle cancel | |
cancelButton.onclick = () => { | |
dialog.style.display = 'none'; | |
}; | |
// Handle confirm | |
confirmButton.onclick = () => { | |
// Get user filename | |
let filename = filenameInput.value.trim(); | |
// Add default if empty | |
if (!filename) { | |
filename = baseFilename + '_pointcloud'; | |
} | |
// Add extension if not present | |
const ext = `.${type.toLowerCase()}`; | |
if (!filename.toLowerCase().endsWith(ext)) { | |
filename += ext; | |
} | |
// Hide dialog | |
dialog.style.display = 'none'; | |
// Perform the actual export | |
if (type === 'ply') { | |
this._generatePLYFile(filename); | |
} else if (type === 'glb') { | |
this._generateGLBFile(filename); | |
} | |
}; | |
} | |
exportToPLY() { | |
if (this.pointClouds.length === 0) { | |
console.log('No point clouds to export'); | |
return; | |
} | |
this.showExportDialog('ply'); | |
} | |
exportToGLB() { | |
if (this.pointClouds.length === 0) { | |
console.log('No point clouds to export'); | |
return; | |
} | |
this.showExportDialog('glb'); | |
} | |
// Internal method to generate and download the GLB file | |
_generateGLBFile(filename) { | |
// Create a new scene containing all the point clouds | |
const exportScene = new THREE.Scene(); | |
// Clone all point clouds to the export scene | |
for (const cloud of this.pointClouds) { | |
const clonedCloud = cloud.clone(); | |
exportScene.add(clonedCloud); | |
} | |
// Create GLTFExporter | |
const exporter = new THREE.GLTFExporter(); | |
// Define export options | |
const options = { | |
binary: true, // Export as GLB (binary) | |
animations: [], // No animations | |
onlyVisible: true | |
}; | |
// Export the scene | |
exporter.parse(exportScene, (result) => { | |
// Create download link for the GLB binary | |
const blob = new Blob([result], { type: 'application/octet-stream' }); | |
const url = URL.createObjectURL(blob); | |
const a = document.createElement('a'); | |
a.href = url; | |
a.download = filename; | |
a.style.display = 'none'; | |
document.body.appendChild(a); | |
a.click(); | |
document.body.removeChild(a); | |
URL.revokeObjectURL(url); | |
console.log(`Exported point clouds to GLB file: ${filename}`); | |
}, options); | |
} | |
// Fixed internal method to generate and download the PLY file with chunked approach | |
_generatePLYFile(filename) { | |
// Show loading message | |
document.getElementById('loadingMessage').style.display = 'block'; | |
document.getElementById('loadingMessage').textContent = 'Generating PLY file... Please wait.'; | |
// Count total number of vertices | |
let totalVertices = 0; | |
for (const cloud of this.pointClouds) { | |
totalVertices += cloud.geometry.attributes.position.count; | |
} | |
// Create PLY header | |
const header = [ | |
'ply', | |
'format ascii 1.0', | |
'comment Created by Binary Point Cloud Viewer', | |
`element vertex ${totalVertices}`, | |
'property float x', | |
'property float y', | |
'property float z', | |
'property uchar red', | |
'property uchar green', | |
'property uchar blue', | |
'end_header' | |
].join('\n') + '\n'; | |
// Array to store all blobs | |
const blobParts = [header]; | |
// Process point clouds with setTimeout to avoid blocking UI | |
const processClouds = (index = 0, chunkSize = 100000) => { | |
if (index >= this.pointClouds.length) { | |
// All clouds processed, create final blob and download | |
const finalBlob = new Blob(blobParts, { type: 'text/plain' }); | |
const url = URL.createObjectURL(finalBlob); | |
const a = document.createElement('a'); | |
a.href = url; | |
a.download = filename; | |
a.style.display = 'none'; | |
document.body.appendChild(a); | |
a.click(); | |
document.body.removeChild(a); | |
URL.revokeObjectURL(url); | |
// Hide loading message | |
document.getElementById('loadingMessage').style.display = 'none'; | |
console.log(`Exported ${totalVertices} points to PLY file: ${filename}`); | |
return; | |
} | |
// Get current cloud | |
const cloud = this.pointClouds[index]; | |
const positions = cloud.geometry.attributes.position.array; | |
const colors = cloud.geometry.attributes.color.array; | |
const count = cloud.geometry.attributes.position.count; | |
// Get the point cloud's world position | |
const worldPos = cloud.position; | |
// Process points in smaller chunks to avoid string size limits | |
const processPoints = (startIdx = 0) => { | |
// Calculate end index for this chunk | |
const endIdx = Math.min(startIdx + chunkSize, count); | |
// Create chunk string | |
let chunkContent = ''; | |
// Add vertices for this chunk | |
for (let i = startIdx; i < endIdx; i++) { | |
const idx = i * 3; | |
// Calculate world coordinates | |
const x = positions[idx] + worldPos.x; | |
const y = positions[idx + 1] + worldPos.y; | |
const z = positions[idx + 2] + worldPos.z; | |
// Convert normalized colors [0,1] to RGB [0,255] | |
const r = Math.floor(colors[idx] * 255); | |
const g = Math.floor(colors[idx + 1] * 255); | |
const b = Math.floor(colors[idx + 2] * 255); | |
// Add vertex to chunk | |
chunkContent += `${x} ${y} ${z} ${r} ${g} ${b}\n`; | |
} | |
// Add chunk to blob parts | |
blobParts.push(chunkContent); | |
// Update loading message with progress | |
const totalProcessed = (index / this.pointClouds.length * 100).toFixed(1); | |
const cloudProgress = (endIdx / count * 100).toFixed(1); | |
document.getElementById('loadingMessage').textContent = | |
`Generating PLY file... ${totalProcessed}% (Cloud ${index+1}/${this.pointClouds.length}: ${cloudProgress}%)`; | |
// Process next chunk or next cloud | |
if (endIdx < count) { | |
setTimeout(() => processPoints(endIdx), 0); | |
} else { | |
setTimeout(() => processClouds(index + 1), 0); | |
} | |
}; | |
// Start processing points for this cloud | |
processPoints(); | |
}; | |
// Start processing clouds | |
setTimeout(() => processClouds(), 0); | |
} | |
init() { | |
// Create scene | |
this.scene = new THREE.Scene(); | |
this.scene.background = new THREE.Color(0x000000); | |
// Create camera | |
this.camera = new THREE.PerspectiveCamera( | |
75, | |
window.innerWidth / window.innerHeight, | |
0.1, | |
1000 | |
); | |
this.camera.position.set(0, 0, this.cameraRadius); | |
this.camera.lookAt(0, 0, 0); | |
// Create renderer | |
this.renderer = new THREE.WebGLRenderer({ antialias: false }); | |
this.renderer.setSize(window.innerWidth, window.innerHeight); | |
document.getElementById('container').appendChild(this.renderer.domElement); | |
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); | |
// Add grid helper | |
const gridHelper = new THREE.GridHelper(10, 10); | |
this.scene.add(gridHelper); | |
// Add axes helper | |
const axesHelper = new THREE.AxesHelper(5); | |
this.scene.add(axesHelper); | |
// Add ambient light | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
this.scene.add(ambientLight); | |
// Add directional light | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); | |
directionalLight.position.set(1, 1, 1); | |
this.scene.add(directionalLight); | |
// Handle window resize | |
window.addEventListener('resize', () => { | |
this.camera.aspect = window.innerWidth / window.innerHeight; | |
this.camera.updateProjectionMatrix(); | |
this.renderer.setSize(window.innerWidth, window.innerHeight); | |
}); | |
} | |
setupEventListeners() { | |
const fileInput = document.getElementById('fileInput'); | |
const processButton = document.getElementById('processButton'); | |
const resetButton = document.getElementById('resetButton'); | |
const exportPlyButton = document.getElementById('exportPlyButton'); | |
const exportGlbButton = document.getElementById('exportGlbButton'); | |
fileInput.addEventListener('change', (event) => { | |
const file = event.target.files[0]; | |
if (!file) return; | |
this.originalFileName = file.name; | |
const fileInfoDiv = document.getElementById('fileInfo'); | |
fileInfoDiv.innerHTML = ` | |
<strong>File:</strong> ${file.name}<br> | |
<strong>Size:</strong> ${(file.size / (1024 * 1024)).toFixed(2)} MB | |
`; | |
const reader = new FileReader(); | |
reader.onload = (e) => { | |
this.fileBuffer = e.target.result; | |
processButton.disabled = false; | |
exportPlyButton.disabled = true; // Disable export until point clouds are created | |
exportGlbButton.disabled = true; // Disable export until point clouds are created | |
}; | |
reader.readAsArrayBuffer(file); | |
}); | |
processButton.addEventListener('click', () => { | |
if (!this.fileBuffer) return; | |
this.processFile(); | |
exportPlyButton.disabled = false; // Enable export after processing | |
exportGlbButton.disabled = false; // Enable export after processing | |
}); | |
resetButton.addEventListener('click', () => { | |
this.resetCamera(); | |
}); | |
exportPlyButton.addEventListener('click', () => { | |
this.exportToPLY(); | |
}); | |
exportGlbButton.addEventListener('click', () => { | |
this.exportToGLB(); | |
}); | |
} | |
resetCamera() { | |
new TWEEN.Tween(this.camera.position) | |
.to({ | |
x: 0, | |
y: 0, | |
z: this.cameraRadius | |
}, 1000) | |
.easing(TWEEN.Easing.Quadratic.InOut) | |
.start(); | |
this.cameraAngle = 0; | |
} | |
processFile() { | |
// Clear existing point clouds | |
this.clearPointClouds(); | |
// Get user options | |
const dataType = document.getElementById('dataType').value; | |
const chunkSizeMB = parseFloat(document.getElementById('chunkSize').value); | |
const gridSize = parseInt(document.getElementById('gridSize').value); | |
const pointSize = parseFloat(document.getElementById('pointSize').value); | |
const isLittleEndian = document.getElementById('endianness').value === 'true'; | |
// Calculate chunk size in bytes | |
const chunkSize = Math.floor(chunkSizeMB * 1024 * 1024); | |
// Show loading message | |
document.getElementById('loadingMessage').style.display = 'block'; | |
// Reset total points counter | |
this.totalPoints = 0; | |
// Process file in the next frame to allow UI update | |
setTimeout(() => { | |
this.createPointCloudLattice( | |
this.fileBuffer, | |
dataType, | |
chunkSize, | |
gridSize, | |
pointSize, | |
isLittleEndian | |
); | |
// Hide loading message | |
document.getElementById('loadingMessage').style.display = 'none'; | |
// Update stats display | |
this.updateStatsDisplay(); | |
}, 100); | |
} | |
clearPointClouds() { | |
for (const cloud of this.pointClouds) { | |
this.scene.remove(cloud); | |
} | |
this.pointClouds = []; | |
this.totalPoints = 0; | |
this.updateStatsDisplay(); | |
} | |
updateStatsDisplay() { | |
const statsDiv = document.getElementById('statsInfo'); | |
if (this.totalPoints > 0) { | |
statsDiv.innerHTML = `<strong>Total Points:</strong> ${this.totalPoints.toLocaleString()}`; | |
} else { | |
statsDiv.innerHTML = ''; | |
} | |
} | |
createPointCloudLattice(buffer, dataType, chunkSize, gridSize, pointSize, isLittleEndian) { | |
// Calculate how many chunks we need | |
const totalChunks = Math.min( | |
gridSize * gridSize * gridSize, | |
Math.floor(buffer.byteLength / chunkSize) + 1 | |
); | |
// Calculate spacing between point clouds | |
const spacing = 2.5; | |
const offset = (gridSize - 1) * spacing / 2; | |
console.log(`Creating point cloud lattice with ${totalChunks} chunks`); | |
// Distribute chunks in a 3D grid | |
let chunkIndex = 0; | |
for (let x = 0; x < gridSize && chunkIndex < totalChunks; x++) { | |
for (let y = 0; y < gridSize && chunkIndex < totalChunks; y++) { | |
for (let z = 0; z < gridSize && chunkIndex < totalChunks; z++) { | |
// Calculate chunk position in the grid | |
const posX = (x * spacing) - offset; | |
const posY = (y * spacing) - offset; | |
const posZ = (z * spacing) - offset; | |
// Calculate chunk start and end offsets | |
const startOffset = chunkIndex * chunkSize; | |
const endOffset = Math.min(startOffset + chunkSize, buffer.byteLength); | |
// Check if we have enough data for this chunk | |
if (startOffset >= buffer.byteLength) { | |
break; | |
} | |
// Extract chunk buffer | |
const chunkBuffer = buffer.slice(startOffset, endOffset); | |
// Process chunk data | |
const processedData = processDataAs(chunkBuffer, dataType, isLittleEndian); | |
// Create point cloud | |
const pointCloud = this.createPointCloud( | |
processedData, | |
pointSize, | |
posX, | |
posY, | |
posZ | |
); | |
// Add to scene | |
this.scene.add(pointCloud); | |
this.pointClouds.push(pointCloud); | |
// Add to total points count | |
this.totalPoints += processedData.numPoints; | |
chunkIndex++; | |
} | |
} | |
} | |
// Adjust camera distance based on grid size | |
this.cameraRadius = Math.max(5, gridSize * 2); | |
this.resetCamera(); | |
console.log(`Created ${this.pointClouds.length} point clouds with ${this.totalPoints.toLocaleString()} total points`); | |
} | |
createPointCloud(processedData, pointSize, x, y, z) { | |
const { points, colors, numPoints } = processedData; | |
// Create buffer geometry | |
const geometry = new THREE.BufferGeometry(); | |
// Slice arrays to actual size | |
const actualPoints = points.slice(0, numPoints * 3); | |
const actualColors = colors.slice(0, numPoints * 3); | |
// Set position attributes | |
geometry.setAttribute('position', new THREE.BufferAttribute(actualPoints, 3)); | |
// Set color attributes | |
geometry.setAttribute('color', new THREE.BufferAttribute(actualColors, 3)); | |
// Create point cloud material | |
const material = new THREE.PointsMaterial({ | |
size: pointSize, | |
vertexColors: true, | |
sizeAttenuation: true | |
}); | |
// Create points object | |
const pointCloud = new THREE.Points(geometry, material); | |
// Set position | |
pointCloud.position.set(x, y, z); | |
return pointCloud; | |
} | |
animate() { | |
requestAnimationFrame(() => this.animate()); | |
// Update TWEEN | |
TWEEN.update(); | |
if (this.controls) this.controls.update(); | |
this.renderer.render(this.scene, this.camera); | |
} | |
} | |
// Initialize application | |
const app = new BinaryPointCloudViewer(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment