Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active June 16, 2025 19: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: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #fff;
background-color: #000;
}
#container {
position: absolute;
width: 100%;
height: 100%;
}
#controls {
position: absolute;
top: 15px;
left: 15px;
padding: 12px;
background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(40, 40, 40, 0.9));
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 100;
width: 220px;
font-size: 12px;
}
#controls h3 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #4fc3f7;
text-align: center;
border-bottom: 1px solid rgba(79, 195, 247, 0.3);
padding-bottom: 6px;
}
.control-group {
margin-bottom: 8px;
}
.control-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.control-row label {
flex: 1;
font-size: 11px;
color: #ccc;
margin: 0;
min-width: 70px;
width: 70px;
}
.control-row input, .control-row select {
flex: 1.2;
padding: 3px 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 11px;
height: 24px;
box-sizing: border-box;
}
.control-row input:focus, .control-row select:focus {
outline: none;
border-color: #4fc3f7;
box-shadow: 0 0 4px rgba(79, 195, 247, 0.3);
}
#fileInput {
width: 100%;
padding: 4px;
border: 1px dashed rgba(79, 195, 247, 0.5);
border-radius: 4px;
background: rgba(79, 195, 247, 0.1);
color: #fff;
font-size: 11px;
cursor: pointer;
margin-bottom: 6px;
}
#fileInput:hover {
border-color: #4fc3f7;
background: rgba(79, 195, 247, 0.2);
}
button {
width: 100%;
background: linear-gradient(145deg, #1976d2, #1565c0);
color: white;
border: none;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: all 0.2s ease;
margin-bottom: 3px;
}
button:hover:not(:disabled) {
background: linear-gradient(145deg, #1565c0, #0d47a1);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(21, 101, 192, 0.3);
}
button:disabled {
background: linear-gradient(145deg, #424242, #212121);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.button-group {
display: flex;
gap: 4px;
}
.button-group button {
flex: 1;
margin-bottom: 0;
}
#fileInfo, #statsInfo {
font-size: 10px;
color: #999;
padding: 4px 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
margin-bottom: 6px;
border-left: 3px solid #4fc3f7;
}
#fileInfo strong, #statsInfo strong {
color: #4fc3f7;
}
#loadingMessage {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(40, 40, 40, 0.9));
backdrop-filter: blur(10px);
padding: 20px 30px;
border-radius: 8px;
border: 1px solid rgba(79, 195, 247, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 101;
font-size: 14px;
color: #4fc3f7;
text-align: center;
}
#exportDialog {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(40, 40, 40, 0.9));
backdrop-filter: blur(10px);
border: 1px solid rgba(79, 195, 247, 0.3);
padding: 20px;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 102;
width: 280px;
}
#exportDialog h3 {
margin: 0 0 15px 0;
color: #4fc3f7;
font-size: 16px;
text-align: center;
}
#exportDialog input {
width: 100%;
margin-bottom: 15px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
box-sizing: border-box;
}
#exportDialog .buttons {
display: flex;
gap: 10px;
}
#exportDialog .buttons button {
flex: 1;
margin-bottom: 0;
}
#exportFormat {
font-size: 12px;
color: #999;
margin-bottom: 15px;
text-align: center;
}
/* Scrollbar styling for webkit browsers */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(79, 195, 247, 0.6);
border-radius: 2px;
}
</style>
</head>
<body>
<div id="container"></div>
<div id="controls">
<h3>🎯 Point Cloud Viewer</h3>
<input type="file" id="fileInput">
<div id="fileInfo"></div>
<div id="statsInfo"></div>
<div class="control-group">
<div class="control-row">
<label for="dataType">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>
<option value="fp16">Float16</option>
<option value="fp32">Float32</option>
</select>
</div>
<div class="control-row">
<label for="startOffset">Start Offset:</label>
<input type="number" id="startOffset" min="0" step="1" value="0">
</div>
<div class="control-row">
<label for="chunkSize">Chunk (MB):</label>
<input type="number" id="chunkSize" min="0.1" max="100" step="0.1" value="1">
</div>
<div class="control-row">
<label for="gridSize">Grid:</label>
<input type="number" id="gridSize" min="1" max="10" step="1" value="3">
</div>
<div class="control-row">
<label for="spacing">Gap:</label>
<input type="number" id="spacing" min="0.5" max="10" step="0.1" value="2.5">
</div>
<div class="control-row">
<label for="pointSize">Size:</label>
<input type="number" id="pointSize" min="0.005" max="1.5" step="0.01" value="0.005">
</div>
<div class="control-row">
<label for="endianness">Endian:</label>
<select id="endianness">
<option value="true">Little</option>
<option value="false">Big</option>
</select>
</div>
<div class="control-row">
<label for="useQuantization">Quantize:</label>
<input type="checkbox" id="useQuantization" checked style="width: auto; flex: none;">
<span style="font-size: 10px; color: #999; margin-left: 4px;">Remove duplicates</span>
</div>
<div class="control-row">
<label for="quantizationBits">Q-Bits:</label>
<input type="number" id="quantizationBits" min="2" max="10" step="1" value="8">
</div>
</div>
<button id="processButton" disabled>🔄 Process File</button>
<button id="resetButton">🎯 Reset View</button>
<div class="button-group">
<button id="exportPlyButton" disabled>📄 PLY</button>
<button id="exportGlbButton" disabled>📦 GLB</button>
</div>
</div>
<div id="loadingMessage">
<div>⏳ Processing data...</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">Please wait</div>
</div>
<div id="exportDialog">
<h3>Export File</h3>
<label for="exportFilename" style="display: block; margin-bottom: 8px; font-size: 12px;">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', isFloat: false },
uint8: { size: 1, min: 0, max: 255, method: 'getUint8', isFloat: false },
int16: { size: 2, min: -32768, max: 32767, method: 'getInt16', isFloat: false },
uint16: { size: 2, min: 0, max: 65535, method: 'getUint16', isFloat: false },
int32: { size: 4, min: -2147483648, max: 2147483647, method: 'getInt32', isFloat: false },
uint32: { size: 4, min: 0, max: 4294967295, method: 'getUint32', isFloat: false },
fp16: { size: 2, method: 'getFloat16', isFloat: true },
fp32: { size: 4, method: 'getFloat32', isFloat: true }
};
// Pre-calculate normalization multipliers for integer types
const NORMALIZERS = Object.fromEntries(
Object.entries(DATA_TYPES)
.filter(([type, config]) => !config.isFloat)
.map(([type, config]) => [
type,
{
multiplier: 2 / (config.max - config.min),
offset: config.min
}
])
);
/**
* Convert IEEE 754 half-precision (fp16) to single precision (fp32)
* @param {number} uint16Value - 16-bit unsigned integer representing fp16
* @returns {number} - JavaScript number (fp32/fp64)
*/
function fp16ToFloat32(uint16Value) {
const sign = (uint16Value & 0x8000) >> 15;
const exponent = (uint16Value & 0x7C00) >> 10;
const mantissa = uint16Value & 0x03FF;
if (exponent === 0) {
if (mantissa === 0) {
// Zero
return sign === 0 ? 0.0 : -0.0;
} else {
// Subnormal
return (sign === 0 ? 1 : -1) * Math.pow(2, -14) * (mantissa / 1024);
}
} else if (exponent === 31) {
if (mantissa === 0) {
// Infinity
return sign === 0 ? Infinity : -Infinity;
} else {
// NaN
return NaN;
}
} else {
// Normal
return (sign === 0 ? 1 : -1) * Math.pow(2, exponent - 15) * (1 + mantissa / 1024);
}
}
/**
* Extended DataView with fp16 support
*/
function createExtendedDataView(buffer) {
const view = new DataView(buffer);
// Add fp16 support
view.getFloat16 = function(byteOffset, littleEndian = false) {
const uint16Value = this.getUint16(byteOffset, littleEndian);
return fp16ToFloat32(uint16Value);
};
return view;
}
/**
* 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 = createExtendedDataView(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);
// Setup normalization function based on data type
const readMethod = view[config.method].bind(view);
let normalize;
if (config.isFloat) {
// Use tanh for floating point normalization
normalize = value => {
// Handle special values
if (!isFinite(value)) {
return isNaN(value) ? 0 : (value > 0 ? 1 : -1);
}
// Apply tanh for smooth [-1, 1] mapping
return Math.tanh(value);
};
} else {
// Use linear normalization for integers
const { multiplier, offset } = NORMALIZERS[dataType];
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 (map from [-1,1] to [0,1] for Three.js)
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
};
}
/**
* Process binary data into quantized normalized 3D points with colors (removes duplicates)
* @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
* @param {number} quantizationBits - Number of bits for quantization (2-10)
* @returns {{ points: Float32Array, colors: Float32Array, numPoints: number }}
*/
function quantizeProcessDataAs(buffer, dataType, isLittleEndian, quantizationBits) {
// Input validation
if (!buffer || !(buffer instanceof ArrayBuffer)) {
throw new Error('Invalid buffer provided - must be an ArrayBuffer');
}
const config = DATA_TYPES[dataType];
if (!config) {
throw new Error(`Unsupported data type: ${dataType}. Supported types: ${Object.keys(DATA_TYPES).join(', ')}`);
}
// Validate quantization bits
if (quantizationBits < 2 || quantizationBits > 10) {
throw new Error('Quantization bits must be between 2 and 10');
}
const typeSize = config.size;
const tupleSize = typeSize * 3;
if (buffer.byteLength < tupleSize) {
throw new Error(`Buffer too small for data type ${dataType}. Need at least ${tupleSize} bytes, got ${buffer.byteLength}`);
}
const view = createExtendedDataView(buffer);
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);
// Setup normalization function based on data type
const readMethod = view[config.method].bind(view);
let normalize;
if (config.isFloat) {
// Use tanh for floating point normalization
normalize = value => {
// Handle special values
if (!isFinite(value)) {
return isNaN(value) ? 0 : (value > 0 ? 1 : -1);
}
// Apply tanh for smooth [-1, 1] mapping
return Math.tanh(value);
};
} else {
// Use linear normalization for integers
const { multiplier, offset } = NORMALIZERS[dataType];
normalize = value => ((value - offset) * multiplier) - 1;
}
let pointIndex = 0;
let baseOffset = 0;
// Calculate quantization parameters based on bit size
const qRange = Math.pow(2, quantizationBits);
const qHalfRange = qRange / 2;
const qMaxIndex = qRange - 1;
// Bit array sized for qRange^3 possible quantized positions
const totalQuantizedPositions = qRange * qRange * qRange;
const bitArraySizeInUint32 = Math.ceil(totalQuantizedPositions / 32);
const tupleBitArray = new Uint32Array(bitArraySizeInUint32);
// Calculate bit shifts for index generation based on quantization bits
const yShift = quantizationBits;
const zShift = quantizationBits * 2;
console.log(`Using ${quantizationBits}-bit quantization: ${qRange}³ = ${totalQuantizedPositions.toLocaleString()} possible positions`);
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));
// Quantize coordinates: map [-1,1] to [0,qMaxIndex] with bounds checking
const qx = Math.max(0, Math.min(qMaxIndex, Math.floor((x + 1) * qHalfRange)));
const qy = Math.max(0, Math.min(qMaxIndex, Math.floor((y + 1) * qHalfRange)));
const qz = Math.max(0, Math.min(qMaxIndex, Math.floor((z + 1) * qHalfRange)));
// Create unique index for this quantized position using variable bit shifts
const qIndex = (qz << zShift) | (qy << yShift) | qx;
// Check if we've seen this quantized position before
const elementIndex = qIndex >> 5;
const bitPosition = qIndex & 0x1F;
const mask = 1 << bitPosition;
if ((tupleBitArray[elementIndex] & mask) === 0) {
// Mark this position as seen
tupleBitArray[elementIndex] |= mask;
// Store points (original normalized coordinates, not quantized)
points[pointIndex] = x;
points[pointIndex + 1] = y;
points[pointIndex + 2] = z;
// Store colors (mapped from [-1,1] to [0,1] for Three.js)
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 what we've processed so far rather than failing completely
}
// Trim arrays to actual size used
const actualPoints = new Float32Array(points.buffer, 0, pointIndex);
const actualColors = new Float32Array(colors.buffer, 0, pointIndex);
return {
points: actualPoints,
colors: actualColors,
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);
}
// Memory-efficient PLY export using streaming approach
_generatePLYFile(filename) {
// Show loading message
document.getElementById('loadingMessage').style.display = 'block';
document.getElementById('loadingMessage').innerHTML = '<div>📁 Generating PLY file...</div><div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">Preparing export...</div>';
try {
// Count total number of vertices
let totalVertices = 0;
for (const cloud of this.pointClouds) {
totalVertices += cloud.geometry.attributes.position.count;
}
// Check if dataset is very large and warn user
if (totalVertices > 10000000) { // 10M points
console.log(`Warning: Large dataset detected (${totalVertices.toLocaleString()} points). This may take several minutes.`);
}
// 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';
// Use smaller chunks for very large datasets to prevent memory issues
const baseChunkSize = totalVertices > 5000000 ? 25000 : 50000;
let processedVertices = 0;
const chunks = [];
// Add header as first chunk
chunks.push(new Blob([header], { type: 'text/plain' }));
// Process point clouds with smaller memory footprint
const processClouds = (cloudIndex = 0) => {
if (cloudIndex >= this.pointClouds.length) {
// All clouds processed, create final blob and download
this._downloadBlobChunks(chunks, filename, totalVertices);
return;
}
// Get current cloud
const cloud = this.pointClouds[cloudIndex];
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 very small chunks to avoid memory spikes
const processPoints = (startIdx = 0) => {
try {
// Calculate end index for this chunk
const endIdx = Math.min(startIdx + baseChunkSize, count);
// Use array for better performance than string concatenation
const lines = [];
// 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 line
lines.push(`${x} ${y} ${z} ${r} ${g} ${b}`);
}
// Create blob for this chunk and add to chunks array
const chunkContent = lines.join('\n') + '\n';
chunks.push(new Blob([chunkContent], { type: 'text/plain' }));
// Update counters
processedVertices += (endIdx - startIdx);
// Update progress
const overallProgress = (processedVertices / totalVertices * 100).toFixed(1);
const cloudProgress = (endIdx / count * 100).toFixed(1);
const memoryUsage = (chunks.length * baseChunkSize * 50 / 1024 / 1024).toFixed(1); // Rough estimate
document.getElementById('loadingMessage').innerHTML =
`<div>📁 Generating PLY file...</div><div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">${overallProgress}% (${processedVertices.toLocaleString()}/${totalVertices.toLocaleString()} points)<br>Cloud ${cloudIndex+1}/${this.pointClouds.length}: ${cloudProgress}%</div>`;
// Process next chunk or next cloud
if (endIdx < count) {
// Use shorter timeout for smaller chunks to maintain responsiveness
setTimeout(() => processPoints(endIdx), 5);
} else {
setTimeout(() => processClouds(cloudIndex + 1), 10);
}
} catch (error) {
console.error('Error processing points chunk:', error);
this._handleExportError('Memory error during point processing. Try reducing grid size or chunk size.');
}
};
// Start processing points for this cloud
processPoints();
};
// Start processing clouds
setTimeout(() => processClouds(), 100);
} catch (error) {
console.error('Error during PLY export setup:', error);
this._handleExportError('Failed to initialize PLY export. The dataset may be too large.');
}
}
// Helper method to download blob chunks efficiently
_downloadBlobChunks(chunks, filename, totalVertices) {
try {
document.getElementById('loadingMessage').innerHTML =
'<div>📁 Finalizing file...</div><div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">Creating download...</div>';
// Create final blob from all chunks
const finalBlob = new Blob(chunks, { type: 'text/plain' });
const url = URL.createObjectURL(finalBlob);
// Download file
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);
// Clean up
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
// Hide loading message
document.getElementById('loadingMessage').style.display = 'none';
console.log(`Successfully exported ${totalVertices.toLocaleString()} points to PLY file: ${filename}`);
} catch (error) {
console.error('Error during file download:', error);
this._handleExportError('Failed to create download file. The file may be too large for your browser.');
}
}
// Helper method to handle export errors gracefully
_handleExportError(message) {
document.getElementById('loadingMessage').innerHTML =
`<div style="color: #ff6b6b;">❌ Export Failed</div><div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">${message}</div>`;
setTimeout(() => {
document.getElementById('loadingMessage').style.display = 'none';
}, 5000);
console.error('PLY Export Error:', message);
}
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 startOffset = parseInt(document.getElementById('startOffset').value) || 0;
const chunkSizeMB = parseFloat(document.getElementById('chunkSize').value);
const gridSize = parseInt(document.getElementById('gridSize').value);
const spacing = parseFloat(document.getElementById('spacing').value);
const pointSize = parseFloat(document.getElementById('pointSize').value);
const isLittleEndian = document.getElementById('endianness').value === 'true';
const useQuantization = document.getElementById('useQuantization').checked;
const quantizationBits = parseInt(document.getElementById('quantizationBits').value) || 8;
// Validate quantization bits
if (quantizationBits < 2 || quantizationBits > 10) {
console.log('Quantization bits must be between 2 and 10, using default 8');
document.getElementById('quantizationBits').value = 8;
return;
}
// Validate start offset
if (startOffset < 0) {
console.log('Start offset cannot be negative, using 0');
document.getElementById('startOffset').value = 0;
return;
}
if (startOffset >= this.fileBuffer.byteLength) {
console.log(`Start offset (${startOffset}) is beyond file size (${this.fileBuffer.byteLength}), using 0`);
document.getElementById('startOffset').value = 0;
return;
}
// Calculate chunk size in bytes
const chunkSize = Math.floor(chunkSizeMB * 1024 * 1024);
// Show loading message
const loadingMsg = document.getElementById('loadingMessage');
loadingMsg.style.display = 'block';
let loadingText = `<div>⏳ Processing data...</div><div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">`;
if (useQuantization) {
const qRange = Math.pow(2, quantizationBits);
loadingText += `Using ${quantizationBits}-bit quantization (${qRange}³ positions)<br>`;
} else {
loadingText += 'Standard processing<br>';
}
loadingText += `Start offset: ${startOffset} bytes</div>`;
loadingMsg.innerHTML = loadingText;
// Reset total points counter
this.totalPoints = 0;
// Process file asynchronously to allow UI updates
setTimeout(async () => {
await this.createPointCloudLattice(
this.fileBuffer,
dataType,
startOffset,
chunkSize,
gridSize,
spacing,
pointSize,
isLittleEndian,
useQuantization,
quantizationBits
);
}, 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) {
const useQuantization = document.getElementById('useQuantization').checked;
const quantizationBits = parseInt(document.getElementById('quantizationBits').value) || 8;
const startOffset = parseInt(document.getElementById('startOffset').value) || 0;
const dataType = document.getElementById('dataType').value;
const config = DATA_TYPES[dataType];
let statsText = `<strong>Points:</strong> ${this.totalPoints.toLocaleString()}`;
if (startOffset > 0) {
statsText += `<br><strong>Offset:</strong> ${startOffset} bytes`;
}
statsText += `<br><strong>Type:</strong> ${dataType.toUpperCase()}`;
if (config.isFloat) {
statsText += ` (tanh normalized)`;
} else {
statsText += ` (linear normalized)`;
}
if (useQuantization) {
const qRange = Math.pow(2, quantizationBits);
statsText += `<br><strong>Method:</strong> ${quantizationBits}-bit quantized (${qRange}³)`;
} else {
statsText += `<br><strong>Method:</strong> Standard`;
}
statsDiv.innerHTML = statsText;
} else {
statsDiv.innerHTML = '';
}
}
async createPointCloudLattice(buffer, dataType, startOffset, chunkSize, gridSize, spacing, pointSize, isLittleEndian, useQuantization, quantizationBits) {
// Apply start offset to buffer
const effectiveBuffer = startOffset > 0 ? buffer.slice(startOffset) : buffer;
// Calculate how many chunks we need
const totalChunks = Math.min(
gridSize * gridSize * gridSize,
Math.floor(effectiveBuffer.byteLength / chunkSize) + 1
);
// Calculate offset from center based on user-defined spacing
const offset = (gridSize - 1) * spacing / 2;
console.log(`Creating point cloud lattice with ${totalChunks} chunks, spacing: ${spacing}, quantization: ${useQuantization ? quantizationBits + '-bit' : 'off'}, start offset: ${startOffset}`);
const loadingMsg = document.getElementById('loadingMessage');
const startTime = Date.now();
// Process chunks incrementally with progress updates
const processChunk = async (chunkIndex, x, y, z) => {
// Update progress message
const progress = ((chunkIndex + 1) / totalChunks * 100).toFixed(1);
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1);
const pointsProcessed = this.totalPoints.toLocaleString();
loadingMsg.innerHTML = `
<div>🔄 Processing chunks...</div>
<div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">
Chunk ${chunkIndex + 1}/${totalChunks} (${progress}%)<br>
Points: ${pointsProcessed} • Time: ${elapsedTime}s<br>
Position: [${x}, ${y}, ${z}]
</div>
`;
// Calculate chunk position in the grid using user-defined spacing
const posX = (x * spacing) - offset;
const posY = (y * spacing) - offset;
const posZ = (z * spacing) - offset;
// Calculate chunk start and end offsets (relative to effective buffer)
const chunkStartOffset = chunkIndex * chunkSize;
const chunkEndOffset = Math.min(chunkStartOffset + chunkSize, effectiveBuffer.byteLength);
// Check if we have enough data for this chunk
if (chunkStartOffset >= effectiveBuffer.byteLength) {
return false; // Signal to stop processing
}
// Extract chunk buffer
const chunkBuffer = effectiveBuffer.slice(chunkStartOffset, chunkEndOffset);
// Process chunk data using selected method
const processedData = useQuantization ?
quantizeProcessDataAs(chunkBuffer, dataType, isLittleEndian, quantizationBits) :
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;
return true; // Continue processing
};
// Process all chunks with progress updates
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++) {
// Process this chunk
const shouldContinue = await processChunk(chunkIndex, x, y, z);
if (!shouldContinue) break;
chunkIndex++;
// Add small delay to allow UI updates (every few chunks)
if (chunkIndex % 3 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
}
}
// Final progress update
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
loadingMsg.innerHTML = `
<div>✅ Processing complete!</div>
<div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">
${this.pointClouds.length} clouds • ${this.totalPoints.toLocaleString()} points<br>
Completed in ${totalTime}s
</div>
`;
// Adjust camera distance based on grid size and spacing
this.cameraRadius = Math.max(5, (gridSize * spacing) * 1.2);
this.resetCamera();
console.log(`Created ${this.pointClouds.length} point clouds with ${this.totalPoints.toLocaleString()} total points in ${totalTime}s`);
// Hide loading message after a short delay
setTimeout(() => {
loadingMsg.style.display = 'none';
this.updateStatsDisplay();
}, 1500);
}
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
const positionAttribute = new THREE.BufferAttribute(actualPoints, 3);
positionAttribute.setUsage( THREE.StaticDrawUsage );
geometry.setAttribute('position', positionAttribute);
// Set color attributes
const colorAttribute = new THREE.BufferAttribute(actualColors, 3);
colorAttribute.setUsage( THREE.StaticDrawUsage );
geometry.setAttribute('color', colorAttribute);
// 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