Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active September 5, 2025 23:00
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>DataPrism - what's in your file?</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%;
}
#dropZone {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: none;
background: rgba(0, 0, 0, 0.8);
color: white;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 28px;
font-weight: 600;
text-align: center;
backdrop-filter: blur(10px);
border: 3px dashed rgba(79, 195, 247, 0.6);
box-sizing: border-box;
}
#dropZone.dragover {
background: rgba(79, 195, 247, 0.2);
border-color: #4fc3f7;
}
#dropZone .drop-content {
padding: 40px;
border-radius: 12px;
background: linear-gradient(145deg, rgba(20, 20, 20, 0.9), rgba(40, 40, 40, 0.8));
border: 2px solid rgba(79, 195, 247, 0.4);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
#dropZone .drop-icon {
font-size: 48px;
margin-bottom: 16px;
color: #4fc3f7;
}
#dropZone .drop-text {
font-size: 24px;
margin-bottom: 8px;
color: #4fc3f7;
}
#dropZone .drop-subtext {
font-size: 14px;
color: #ccc;
opacity: 0.8;
}
#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;
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top left;
}
#controls.minimized {
transform: scale(0.1);
opacity: 0;
pointer-events: none;
}
#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;
position: relative;
}
#minimizeButton {
position: absolute;
top: -2px;
right: 0;
background: none;
border: none;
color: #4fc3f7;
font-size: 16px;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.2s ease, transform 0.2s ease;
line-height: 1;
width: 24px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
#minimizeButton:hover {
background-color: rgba(79, 195, 247, 0.2);
transform: scale(1.1);
}
#restoreButton {
position: absolute;
top: 15px;
left: 15px;
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.5);
border-radius: 50%;
color: #4fc3f7;
font-size: 18px;
cursor: pointer;
padding: 8px;
width: 40px;
height: 40px;
display: none;
align-items: center;
justify-content: center;
z-index: 101;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
#restoreButton:hover {
background: linear-gradient(145deg, rgba(79, 195, 247, 0.2), rgba(79, 195, 247, 0.1));
border-color: #4fc3f7;
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(79, 195, 247, 0.3);
}
.control-group {
margin-bottom: 8px;
}
.control-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.control-row label {
font-size: 11px;
color: #ccc;
margin: 0;
width: 70px;
min-width: 70px;
flex-shrink: 0;
}
.control-row input, .control-row select {
flex: 1;
width: 0; /* Important: allows flex to work properly */
min-width: 0;
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: 3px 6px;
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;
height: 24px;
box-sizing: border-box;
}
#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;
}
#processButton.highlight {
background: linear-gradient(145deg, #ff9800, #f57c00) !important;
animation: pulse 2s infinite;
box-shadow: 0 0 15px rgba(255, 152, 0, 0.4);
}
@keyframes pulse {
0% { box-shadow: 0 0 15px rgba(255, 152, 0, 0.4); }
50% { box-shadow: 0 0 25px rgba(255, 152, 0, 0.6); }
100% { box-shadow: 0 0 15px rgba(255, 152, 0, 0.4); }
}
#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;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
line-height: 1.3;
}
.filename-truncate {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
cursor: help;
}
#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;
}
/* Highlight 6-tuple mode */
.tuple-mode-indicator {
font-size: 10px;
color: #ff9800;
font-weight: bold;
margin-left: 4px;
}
/* Highlight continuous path mode */
.continuous-path-indicator {
font-size: 10px;
color: #4caf50;
font-weight: bold;
margin-left: 4px;
}
/* Highlight lattice 2d mode */
.lattice-indicator {
font-size: 10px;
color: #e91e63;
font-weight: bold;
margin-left: 4px;
}
/* Highlight tiled mode */
.tiled-indicator {
font-size: 10px;
color: #9c27b0;
font-weight: bold;
margin-left: 4px;
}
/* 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="dropZone">
<div class="drop-content">
<div class="drop-icon">📊</div>
<div class="drop-text">Drop file here</div>
<div class="drop-subtext">Binary data, images, or URLs</div>
</div>
</div>
<div id="controls">
<h3>
🎯 DataPrism
<button id="minimizeButton" title="Minimize controls">−</button>
</h3>
<input type="file" id="fileInput">
<div id="fileInfo"></div>
<div id="statsInfo"></div>
<div class="control-group">
<div class="control-row">
<label for="tupleMode">Mode:</label>
<select id="tupleMode">
<option value="3-tuple">3-Tuple (XYZ)</option>
<option value="6-tuple">6-Tuple (XYZRGB)</option>
</select>
</div>
<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="fp8_e4m3">FP8 (E4M3)</option>
<option value="fp8_e5m2">FP8 (E5M2)</option>
<option value="fp16">Float16</option>
<option value="bf16">BFloat16</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="projectionMode">Projection:</label>
<select id="projectionMode">
<option value="standard">Standard</option>
<option value="continuous-path">Continuous Path</option>
<option value="lattice-2d">Lattice 2D</option>
<option value="tiled">Tiled</option>
<option value="stereographic">Stereographic</option>
<option value="equirectangular">Equirectangular</option>
<option value="orthographic-xy">Orthographic XY</option>
<option value="orthographic-xz">Orthographic XZ</option>
<option value="orthographic-yz">Orthographic YZ</option>
<option value="orthographic-3plane">Orthographic 3-Plane</option>
<option value="cylindrical">Cylindrical</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>
<button id="exportPlyButton" disabled>📄 Export PLY</button>
</div>
<button id="restoreButton" title="Show controls">🎯</button>
<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>
// 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 },
bf16: { size: 2, method: 'getBFloat16', isFloat: true },
fp32: { size: 4, method: 'getFloat32', isFloat: true },
fp8_e4m3: { size: 1, method: 'getFloat8E4M3', isFloat: true },
fp8_e5m2: { size: 1, method: 'getFloat8E5M2', 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
}
])
);
/**
* Apply various 3D to 2D projections to reveal geometric patterns
* @param {Float32Array} points - Array of 3D coordinates [x1,y1,z1,x2,y2,z2,...]
* @param {string} projectionMode - Type of projection to apply
* @param {number} quantizationBits - Number of quantization bits (for tiled projection)
* @returns {Float32Array|Object} - Projected points or object with points and path data
*/
function applyProjection(points, projectionMode, quantizationBits = 8) {
if (projectionMode === 'standard') {
return points; // No projection
}
// Special case for continuous path - return both points and path data
if (projectionMode === 'continuous-path') {
return {
points: points,
pathData: true, // Flag to indicate this chunk should create path lines
numPoints: points.length / 3
};
}
// New Lattice 2D projection case
if (projectionMode === 'lattice-2d') {
const numPoints = points.length / 3;
const latticeSize = Math.ceil(Math.sqrt(numPoints));
// Create new projected points array
const projectedPoints = new Float32Array(points.length);
for (let i = 0; i < numPoints; i++) {
const pointIndex = i * 3;
// Calculate lattice position
const row = Math.floor(i / latticeSize);
const col = i % latticeSize;
// Map to normalized coordinates [-1, 1] with proper spacing
const x = latticeSize > 1 ? (col / (latticeSize - 1)) * 2 - 1 : 0;
const y = latticeSize > 1 ? (row / (latticeSize - 1)) * 2 - 1 : 0;
projectedPoints[pointIndex] = x;
projectedPoints[pointIndex + 1] = y;
projectedPoints[pointIndex + 2] = 0; // Flatten to z=0 plane
}
return projectedPoints;
}
// New Tiled projection case
if (projectionMode === 'tiled') {
const q = quantizationBits;
const qRange = Math.pow(2, q);
const sqrtQRange = Math.floor(Math.sqrt(qRange));
// Create new projected points array
const projectedPoints = new Float32Array(points.length);
for (let i = 0; i < points.length; i += 3) {
const x = points[i];
const y = points[i + 1];
const z = points[i + 2];
// Convert normalized coordinates [-1,1] to discrete grid [0, qRange-1]
const discreteX = Math.max(0, Math.min(qRange - 1, Math.floor((x + 1) / 2 * qRange)));
const discreteY = Math.max(0, Math.min(qRange - 1, Math.floor((y + 1) / 2 * qRange)));
const discreteZ = Math.max(0, Math.min(qRange - 1, Math.floor((z + 1) / 2 * qRange)));
// Apply tiling formula: (col, row) = (z % sqrt(2^q), floor(z / sqrt(2^q)))
const col = discreteZ % sqrtQRange;
const row = Math.floor(discreteZ / sqrtQRange);
// Calculate tiled coordinates: col * 2^q + x, row * 2^q + y
const tiledX = col * qRange + discreteX;
const tiledY = row * qRange + discreteY;
// Normalize back for display (scale to fit in reasonable viewing area)
const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1);
projectedPoints[i] = (tiledX / maxTiledCoord) * 2 - 1;
projectedPoints[i + 1] = (tiledY / maxTiledCoord) * 2 - 1;
projectedPoints[i + 2] = 0; // Flatten to z=0 plane
}
return projectedPoints;
}
// Special case for 3-plane orthographic - creates 3x more points
if (projectionMode === 'orthographic-3plane') {
const projectedPoints = new Float32Array(points.length * 3); // Triple the size
for (let i = 0; i < points.length; i += 3) {
const x = points[i];
const y = points[i + 1];
const z = points[i + 2];
// Calculate base index for the three projected points
const baseIdx = i * 3;
// Project onto XY plane (z = 0)
projectedPoints[baseIdx] = x;
projectedPoints[baseIdx + 1] = y;
projectedPoints[baseIdx + 2] = 0;
// Project onto XZ plane (y = 0)
projectedPoints[baseIdx + 3] = x;
projectedPoints[baseIdx + 4] = 0;
projectedPoints[baseIdx + 5] = z;
// Project onto YZ plane (x = 0)
projectedPoints[baseIdx + 6] = 0;
projectedPoints[baseIdx + 7] = y;
projectedPoints[baseIdx + 8] = z;
}
return projectedPoints;
}
// Standard single-point projections
const projectedPoints = new Float32Array(points.length);
for (let i = 0; i < points.length; i += 3) {
let x = points[i];
let y = points[i + 1];
let z = points[i + 2];
switch (projectionMode) {
case 'stereographic':
// Normalize to unit sphere
const magnitude = Math.sqrt(x * x + y * y + z * z);
if (magnitude > 0) {
x /= magnitude;
y /= magnitude;
z /= magnitude;
}
// Stereographic projection from north pole (0,0,1) to z=0 plane
if (z < 0.999) { // Avoid division by zero
const denominator = 1 - z;
const projX = x / denominator;
const projY = y / denominator;
// Scale down the projection for better visualization
const scale = 0.5;
projectedPoints[i] = projX * scale;
projectedPoints[i + 1] = projY * scale;
projectedPoints[i + 2] = 0; // Flatten to z=0 plane
} else {
// Point very close to north pole, place at origin
projectedPoints[i] = 0;
projectedPoints[i + 1] = 0;
projectedPoints[i + 2] = 0;
}
break;
case 'equirectangular':
// Convert cartesian to spherical coordinates
const r = Math.sqrt(x * x + y * y + z * z);
if (r > 0) {
// Spherical coordinates: theta (azimuth), phi (elevation)
const theta = Math.atan2(y, x); // azimuth [-π, π]
const phi = Math.acos(Math.abs(z) / r); // elevation [0, π]
// Map to equirectangular coordinates
// Longitude: theta mapped to [-1, 1]
// Latitude: phi mapped to [-1, 1]
projectedPoints[i] = theta / Math.PI; // maps [-π,π] to [-1,1]
projectedPoints[i + 1] = (phi / Math.PI) * 2 - 1; // maps [0,π] to [-1,1]
projectedPoints[i + 2] = 0; // Flatten to z=0 plane
} else {
projectedPoints[i] = 0;
projectedPoints[i + 1] = 0;
projectedPoints[i + 2] = 0;
}
break;
case 'orthographic-xy':
// Project onto XY plane (view from Z axis)
projectedPoints[i] = x;
projectedPoints[i + 1] = y;
projectedPoints[i + 2] = 0;
break;
case 'orthographic-xz':
// Project onto XZ plane (view from Y axis)
projectedPoints[i] = x;
projectedPoints[i + 1] = z;
projectedPoints[i + 2] = 0;
break;
case 'orthographic-yz':
// Project onto YZ plane (view from X axis)
projectedPoints[i] = y;
projectedPoints[i + 1] = z;
projectedPoints[i + 2] = 0;
break;
case 'cylindrical':
// Cylindrical projection: wrap around Y axis
const radius = Math.sqrt(x * x + z * z);
if (radius > 0) {
// Azimuth angle around Y axis
const angle = Math.atan2(z, x); // [-π, π]
// Map to cylindrical coordinates
projectedPoints[i] = angle / Math.PI; // maps [-π,π] to [-1,1]
projectedPoints[i + 1] = y; // height remains the same
projectedPoints[i + 2] = 0; // Flatten to z=0 plane
} else {
projectedPoints[i] = 0;
projectedPoints[i + 1] = y;
projectedPoints[i + 2] = 0;
}
break;
default:
// Fallback to standard (no projection)
projectedPoints[i] = x;
projectedPoints[i + 1] = y;
projectedPoints[i + 2] = z;
break;
}
}
return projectedPoints;
}
/**
* 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);
}
}
/**
* Convert Google's bfloat16 (bf16) to single precision (fp32)
* @param {number} uint16Value - 16-bit unsigned integer representing bf16
* @returns {number} - JavaScript number (fp32/fp64)
*/
function bf16ToFloat32(uint16Value) {
const sign = (uint16Value & 0x8000) >> 15;
const exponent = (uint16Value & 0x7F80) >> 7; // bits 14-7 (8 bits)
const mantissa = uint16Value & 0x007F; // bits 6-0 (7 bits)
if (exponent === 0) {
if (mantissa === 0) {
// Zero
return sign === 0 ? 0.0 : -0.0;
} else {
// Subnormal
return (sign === 0 ? 1 : -1) * Math.pow(2, -126) * (mantissa / 128);
}
} else if (exponent === 255) {
if (mantissa === 0) {
// Infinity
return sign === 0 ? Infinity : -Infinity;
} else {
// NaN
return NaN;
}
} else {
// Normal
return (sign === 0 ? 1 : -1) * Math.pow(2, exponent - 127) * (1 + mantissa / 128);
}
}
function fp8e4m3ToFloat32(uint8Value) {
const sign = (uint8Value & 0x80) >> 7;
const exponent = (uint8Value & 0x78) >> 3; // 4 bits
const mantissa = uint8Value & 0x07; // 3 bits
if (exponent === 0) {
// Subnormal or zero
if (mantissa === 0) return sign ? -0.0 : 0.0;
return (sign ? -1 : 1) * Math.pow(2, -6) * (mantissa / 8);
}
if (exponent === 0xF) {
// Inf or NaN
return mantissa === 0 ? (sign ? -Infinity : Infinity) : NaN;
}
return (sign ? -1 : 1) * Math.pow(2, exponent - 7) * (1 + mantissa / 8);
}
function fp8e5m2ToFloat32(uint8Value) {
const sign = (uint8Value & 0x80) >> 7;
const exponent = (uint8Value & 0x7C) >> 2; // 5 bits
const mantissa = uint8Value & 0x03; // 2 bits
if (exponent === 0) {
if (mantissa === 0) return sign ? -0.0 : 0.0;
return (sign ? -1 : 1) * Math.pow(2, -14) * (mantissa / 4);
}
if (exponent === 0x1F) {
return mantissa === 0 ? (sign ? -Infinity : Infinity) : NaN;
}
return (sign ? -1 : 1) * Math.pow(2, exponent - 15) * (1 + mantissa / 4);
}
/**
* Extended DataView with fp16 and bf16 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);
};
// Add bf16 support
view.getBFloat16 = function(byteOffset, littleEndian = false) {
const uint16Value = this.getUint16(byteOffset, littleEndian);
return bf16ToFloat32(uint16Value);
};
view.getFloat8E4M3 = function(byteOffset) {
const uint8Value = this.getUint8(byteOffset);
return fp8e4m3ToFloat32(uint8Value);
};
view.getFloat8E5M2 = function(byteOffset) {
const uint8Value = this.getUint8(byteOffset);
return fp8e5m2ToFloat32(uint8Value);
};
return view;
}
/**
* 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)
* @param {string} projectionMode - Projection mode ('standard' or 'stereographic')
* @param {string} tupleMode - Tuple mode ('3-tuple' or '6-tuple')
* @returns {{ points: Float32Array, colors: Float32Array, numPoints: number, pathData?: boolean }}
*/
function quantizeProcessDataAs(buffer, dataType, isLittleEndian, quantizationBits, projectionMode = 'standard', tupleMode = '3-tuple') {
// 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 valuesPerTuple = tupleMode === '6-tuple' ? 6 : 3;
const tupleSize = typeSize * valuesPerTuple;
if (buffer.byteLength < tupleSize) {
throw new Error(`Buffer too small for data type ${dataType} in ${tupleMode} mode. 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
let 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;
// For 6-tuple mode, we need to quantize both coordinates and colors
// But we only deduplicate based on coordinates to preserve color variation
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 in ${tupleMode} mode: ${qRange}³ = ${totalQuantizedPositions.toLocaleString()} possible positions`);
try {
while (baseOffset <= maxOffset) {
// Read and normalize 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;
// Handle colors based on tuple mode
if (tupleMode === '6-tuple') {
// Read explicit color values and normalize to [-1,1] then convert to [0,1]
const r = normalize(readMethod(baseOffset + typeSize * 3, isLittleEndian));
const g = normalize(readMethod(baseOffset + typeSize * 4, isLittleEndian));
const b = normalize(readMethod(baseOffset + typeSize * 5, isLittleEndian));
// Convert from [-1,1] to [0,1] for Three.js rendering
colors[pointIndex] = (r + 1) / 2;
colors[pointIndex + 1] = (g + 1) / 2;
colors[pointIndex + 2] = (b + 1) / 2;
} else {
// Generate colors from coordinates (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 what we've processed so far rather than failing completely
}
// Apply projection if requested
if (projectionMode !== 'standard') {
const projectionResult = applyProjection(points.slice(0, pointIndex), projectionMode, quantizationBits);
// Handle different projection return types
if (projectionResult && projectionResult.pathData) {
// Continuous path mode - return original points with path flag
points = projectionResult.points.slice(0, pointIndex);
return {
points,
colors: colors.slice(0, pointIndex),
numPoints: pointIndex / 3,
pathData: true
};
} else if (projectionMode === 'orthographic-3plane') {
// Handle color expansion for 3-plane orthographic projection
const expandedColors = new Float32Array(colors.length * 3);
for (let i = 0; i < pointIndex; i += 3) {
const baseIdx = i * 3;
// Copy original colors for all three projected points
// XY plane projection
expandedColors[baseIdx] = colors[i];
expandedColors[baseIdx + 1] = colors[i + 1];
expandedColors[baseIdx + 2] = colors[i + 2];
// XZ plane projection
expandedColors[baseIdx + 3] = colors[i];
expandedColors[baseIdx + 4] = colors[i + 1];
expandedColors[baseIdx + 5] = colors[i + 2];
// YZ plane projection
expandedColors[baseIdx + 6] = colors[i];
expandedColors[baseIdx + 7] = colors[i + 1];
expandedColors[baseIdx + 8] = colors[i + 2];
}
return {
points: projectionResult,
colors: expandedColors,
numPoints: (pointIndex / 3) * 3
};
} else {
// Standard projection
points = projectionResult;
}
} else {
points = points.slice(0, pointIndex);
}
return {
points,
colors: colors.slice(0, pointIndex),
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.pathLines = []; // Store path lines separately
this.originalFileName = '';
this.cameraRadius = 5;
this.cameraAngle = 0;
this.totalPoints = 0;
this.controlsMinimized = false;
this.init();
this.setupEventListeners();
this.setupDropZone();
this.setupPasteHandling();
this.checkURLParameters();
this.animate();
}
// Handle clipboard operations
setupPasteHandling() {
document.addEventListener('paste', (e) => {
e.preventDefault();
const items = (e.clipboardData || window.clipboardData).items;
for (let item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
this.handleDragDropFiles([file]);
} else {
item.getAsString((text) => {
this.tryFetchAPI(text);
});
}
}
});
}
// Try to fetch from URL
async tryFetchAPI(src) {
try {
console.log('Attempting to fetch from URL:', src);
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
// Create a fake file from the blob
const file = new File([blob], src.split('/').pop() || 'fetched_file', { type: blob.type });
this.handleDragDropFiles([file]);
} else {
console.log('Failed to fetch URL, response not ok:', response.status);
}
} catch (error) {
console.log('Failed to fetch URL:', error.message);
}
}
// Handle non-file drops (like HTML content with image sources)
handleNonFileDrop(text) {
if (text.startsWith('<meta')) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const img = doc.querySelector('img');
if (img) {
const imgSrc = img.getAttribute('src');
this.tryFetchAPI(imgSrc);
}
} catch (error) {
console.log('Failed to parse dropped HTML content:', error.message);
}
} else if (text.startsWith('http')) {
// Direct URL
this.tryFetchAPI(text);
}
}
// Handle drop events
handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
this.handleDragDropFiles(files);
return;
}
// Handle non-file drops
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i];
if (item.kind === 'string') {
item.getAsString((s) => {
this.handleNonFileDrop(s);
});
}
}
}
// Setup drag and drop functionality
setupDropZone() {
const dropZone = document.getElementById('dropZone');
// Prevent default drag behaviors on the document
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
// Show drop zone when dragging over document
document.addEventListener('dragenter', (e) => {
dropZone.style.display = 'flex';
dropZone.classList.remove('dragover');
});
// Hide drop zone when leaving the drop zone itself
dropZone.addEventListener('dragleave', (e) => {
// Only hide if we're leaving the dropZone entirely, not just moving between child elements
if (!dropZone.contains(e.relatedTarget)) {
dropZone.style.display = 'none';
dropZone.classList.remove('dragover');
}
});
// Highlight drop zone when dragging over it
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
// Handle the actual drop
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.display = 'none';
dropZone.classList.remove('dragover');
this.handleDrop(e);
});
}
// Handle files (from drag and drop or file input)
handleFiles(files) {
if (!files || files.length === 0) return;
const file = files[0]; // Take the first file
this.originalFileName = file.name;
const fileInfoDiv = document.getElementById('fileInfo');
fileInfoDiv.innerHTML = `
<strong>File:</strong> <span class="filename-truncate" title="${file.name}">${file.name}</span> <strong>Size:</strong> ${(file.size / (1024 * 1024)).toFixed(2)} MB
`;
const reader = new FileReader();
reader.onload = (e) => {
this.fileBuffer = e.target.result;
document.getElementById('processButton').disabled = false;
document.getElementById('exportPlyButton').disabled = true;
this.highlightProcessButton(); // Highlight when file is loaded
console.log('File loaded successfully:', file.name);
};
reader.onerror = () => {
console.error('Failed to read file:', file.name);
};
reader.readAsArrayBuffer(file);
}
// Handle files specifically from drag and drop
handleDragDropFiles(files) {
// Clear the file input to prevent conflicts
const fileInput = document.getElementById('fileInput');
fileInput.value = '';
this.handleFiles(files);
}
// Highlight the process button to indicate action is needed
highlightProcessButton() {
const processButton = document.getElementById('processButton');
if (!processButton.disabled) {
processButton.classList.add('highlight');
// Check if this is a new file or changed settings
const isFirstLoad = this.pointClouds.length === 0;
processButton.textContent = isFirstLoad ?
'🔄 Process File (Ready)' :
'🔄 Process File (Changes Ready)';
}
}
// Remove highlight from process button
unhighlightProcessButton() {
const processButton = document.getElementById('processButton');
processButton.classList.remove('highlight');
processButton.textContent = '🔄 Process File';
}
// Check URL parameters for auto-fetch
checkURLParameters() {
const argument = new URL(document.URL).searchParams.get('fetch');
if (argument) {
console.log('Auto-fetching from URL parameter:', argument);
this.tryFetchAPI(argument);
}
}
toggleControls() {
const controls = document.getElementById('controls');
const restoreButton = document.getElementById('restoreButton');
this.controlsMinimized = !this.controlsMinimized;
if (this.controlsMinimized) {
controls.classList.add('minimized');
restoreButton.style.display = 'flex';
} else {
controls.classList.remove('minimized');
restoreButton.style.display = 'none';
}
}
showExportDialog() {
// 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 PLY';
formatDiv.textContent = 'Format: PLY';
// 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 = '.ply';
if (!filename.toLowerCase().endsWith(ext)) {
filename += ext;
}
// Hide dialog
dialog.style.display = 'none';
// Perform the actual export
this._generatePLYFile(filename);
};
}
exportToPLY() {
if (this.pointClouds.length === 0) {
console.log('No point clouds to export');
return;
}
this.showExportDialog();
}
// 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 minimizeButton = document.getElementById('minimizeButton');
const restoreButton = document.getElementById('restoreButton');
const projectionModeSelect = document.getElementById('projectionMode');
const tupleModeSelect = document.getElementById('tupleMode');
// Control panel minimize/restore functionality
minimizeButton.addEventListener('click', () => {
this.toggleControls();
});
restoreButton.addEventListener('click', () => {
this.toggleControls();
});
// Projection mode change handler
projectionModeSelect.addEventListener('change', () => {
// Highlight process button to indicate changes need processing
if (this.fileBuffer) {
this.highlightProcessButton();
}
});
// Tuple mode change handler
tupleModeSelect.addEventListener('change', () => {
// Highlight process button to indicate changes need processing
if (this.fileBuffer) {
this.highlightProcessButton();
}
});
// Add change listeners to other processing-related controls
const processingControls = ['dataType', 'startOffset', 'chunkSize', 'gridSize',
'spacing', 'pointSize', 'endianness', 'useQuantization', 'quantizationBits'];
processingControls.forEach(controlId => {
const control = document.getElementById(controlId);
if (control) {
control.addEventListener('change', () => {
if (this.fileBuffer) {
this.highlightProcessButton();
}
});
control.addEventListener('input', () => {
if (this.fileBuffer) {
this.highlightProcessButton();
}
});
}
});
fileInput.addEventListener('change', (event) => {
this.handleFiles(event.target.files);
});
// Also add a click handler to clear the input first (to handle selecting same file twice)
fileInput.addEventListener('click', (event) => {
// Clear the input value so that selecting the same file will trigger change event
event.target.value = '';
});
processButton.addEventListener('click', () => {
if (!this.fileBuffer) return;
this.processFile();
// Enable export button after processing
setTimeout(() => {
exportPlyButton.disabled = false;
}, 100);
});
resetButton.addEventListener('click', () => {
this.resetCamera();
});
exportPlyButton.addEventListener('click', () => {
this.exportToPLY();
});
}
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() {
// Remove highlight when processing starts
this.unhighlightProcessButton();
// Clear existing point clouds and path lines
this.clearPointClouds();
// Get user options
const tupleMode = document.getElementById('tupleMode').value;
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;
const projectionMode = document.getElementById('projectionMode').value;
// 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;">`;
loadingText += `Mode: <span class="tuple-mode-indicator">${tupleMode.toUpperCase()}</span><br>`;
loadingText += `Projection: ${projectionMode}`;
if (projectionMode === 'continuous-path') {
loadingText += ` <span class="continuous-path-indicator">(PATH)</span>`;
} else if (projectionMode === 'lattice-2d') {
loadingText += ` <span class="lattice-indicator">(LATTICE)</span>`;
} else if (projectionMode === 'tiled') {
loadingText += ` <span class="tiled-indicator">(TILED)</span>`;
} else if (projectionMode === 'orthographic-3plane') {
loadingText += ` (3x points)`;
}
loadingText += `<br>`;
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,
projectionMode,
tupleMode
);
}, 100);
}
clearPointClouds() {
// Remove point clouds
for (const cloud of this.pointClouds) {
this.scene.remove(cloud);
}
this.pointClouds = [];
// Remove path lines
for (const line of this.pathLines) {
this.scene.remove(line);
}
this.pathLines = [];
this.totalPoints = 0;
this.updateStatsDisplay();
}
updateStatsDisplay() {
const statsDiv = document.getElementById('statsInfo');
if (this.totalPoints > 0) {
const tupleMode = document.getElementById('tupleMode').value;
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 projectionMode = document.getElementById('projectionMode').value;
const config = DATA_TYPES[dataType];
let statsText = `<strong>Points:</strong> ${this.totalPoints.toLocaleString()}`;
if (this.pathLines.length > 0) {
statsText += `<br><strong>Paths:</strong> ${this.pathLines.length} <span class="continuous-path-indicator">LINES</span>`;
}
if (startOffset > 0) {
statsText += `<br><strong>Offset:</strong> ${startOffset} bytes`;
}
statsText += `<br><strong>Mode:</strong> <span class="tuple-mode-indicator">${tupleMode.toUpperCase()}</span>`;
statsText += `<br><strong>Type:</strong> ${dataType.toUpperCase()}`;
if (config.isFloat) {
statsText += ` (tanh normalized)`;
} else {
statsText += ` (linear normalized)`;
}
statsText += `<br><strong>Projection:</strong> ${projectionMode}`;
if (projectionMode === 'continuous-path') {
statsText += ` <span class="continuous-path-indicator">(PATH)</span>`;
} else if (projectionMode === 'lattice-2d') {
statsText += ` <span class="lattice-indicator">(LATTICE)</span>`;
} else if (projectionMode === 'tiled') {
statsText += ` <span class="tiled-indicator">(TILED)</span>`;
}
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, projectionMode, tupleMode) {
// 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 in ${tupleMode} mode, spacing: ${spacing}, quantization: ${useQuantization ? quantizationBits + '-bit' : 'off'}, projection: ${projectionMode}, 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();
const projectionInfo = projectionMode === 'continuous-path' ? ` • PATH` :
projectionMode === 'lattice-2d' ? ` • LATTICE` :
projectionMode === 'tiled' ? ` • TILED` :
projectionMode === 'orthographic-3plane' ? ` • 3-Plane` : '';
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>
Mode: <span class="tuple-mode-indicator">${tupleMode.toUpperCase()}</span> • Position: [${x}, ${y}, ${z}]<br>
Projection: ${projectionMode}${projectionInfo}
</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 and projection
const processedData = quantizeProcessDataAs(chunkBuffer, dataType, isLittleEndian, quantizationBits, projectionMode, tupleMode);
// Create point cloud and optionally path lines
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>
${this.pathLines.length > 0 ? `${this.pathLines.length} paths • ` : ''}Mode: <span class="tuple-mode-indicator">${tupleMode.toUpperCase()}</span> • Projection: ${projectionMode}<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 and ${this.pathLines.length} path lines in ${totalTime}s using ${tupleMode} mode with ${projectionMode} projection`);
// 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, pathData } = processedData;
// Create buffer geometry for points
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);
// If this is continuous path mode, also create line geometry
if (pathData && numPoints > 1) {
// Create line geometry connecting consecutive points
const lineGeometry = new THREE.BufferGeometry();
// Create line positions - same as point positions
lineGeometry.setAttribute('position', new THREE.BufferAttribute(actualPoints, 3));
// Create line colors - same as point colors
lineGeometry.setAttribute('color', new THREE.BufferAttribute(actualColors, 3));
// Create line material with smaller width and transparency
const lineMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
opacity: 0.6,
transparent: true,
linewidth: 1
});
// Create line object
const pathLine = new THREE.Line(lineGeometry, lineMaterial);
// Set same position as point cloud
pathLine.position.set(x, y, z);
// Add to scene and track it
this.scene.add(pathLine);
this.pathLines.push(pathLine);
console.log(`Created path line with ${numPoints} connected points at position [${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