Last active
June 16, 2025 19: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: '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