Last active
September 5, 2025 23:00
-
-
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>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