Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active May 14, 2025 02:54
Show Gist options
  • Save lardratboy/4339907ae35281496f8675de7d9f802d to your computer and use it in GitHub Desktop.
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
<!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