Last active
October 3, 2025 05:30
-
-
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 hilbert curve mode */ | |
| .hilbert-indicator { | |
| font-size: 10px; | |
| color: #00bcd4; | |
| font-weight: bold; | |
| margin-left: 4px; | |
| } | |
| /* Highlight BVH mode */ | |
| .bvh-indicator { | |
| font-size: 10px; | |
| color: #ff5722; | |
| 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="hilbert-curve">Hilbert Curve</option> | |
| <option value="bvh-with-points">BVH + Points</option> | |
| <option value="bvh-only">BVH Only</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" id="bvhControls" style="display: none;"> | |
| <label for="bvhMaxDepth">BVH Depth:</label> | |
| <input type="number" id="bvhMaxDepth" min="1" max="12" step="1" value="8"> | |
| </div> | |
| <div class="control-row" id="bvhMinPointsControl" style="display: none;"> | |
| <label for="bvhMinPoints">Min Points:</label> | |
| <input type="number" id="bvhMinPoints" min="1" max="64" step="1" value="8"> | |
| </div> | |
| <div class="control-row" id="bvhLevelControl" style="display: none;"> | |
| <label for="bvhDisplayLevel">Show Level:</label> | |
| <select id="bvhDisplayLevel"> | |
| <option value="-1">All Levels</option> | |
| <option value="0">Level 0 (Root)</option> | |
| <option value="1">Level 1</option> | |
| <option value="2">Level 2</option> | |
| <option value="3">Level 3</option> | |
| <option value="4">Level 4</option> | |
| <option value="5">Level 5</option> | |
| <option value="6">Level 6</option> | |
| <option value="7">Level 7</option> | |
| <option value="8">Level 8</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 | |
| } | |
| ]) | |
| ); | |
| /** | |
| * BVH (Bounding Volume Hierarchy) Implementation | |
| * Creates a binary tree of axis-aligned bounding boxes | |
| */ | |
| const BVH = { | |
| /** | |
| * Build a BVH tree from points | |
| * @param {Float32Array} points - Array of 3D coordinates | |
| * @param {number} maxDepth - Maximum tree depth | |
| * @param {number} minPoints - Minimum points per leaf node | |
| * @returns {Object} Root node of BVH tree | |
| */ | |
| build: function(points, maxDepth = 8, minPoints = 8) { | |
| try { | |
| const numPoints = points.length / 3; | |
| const indices = new Array(numPoints); | |
| for (let i = 0; i < numPoints; i++) indices[i] = i; | |
| console.log(`Building BVH with ${numPoints} points, maxDepth=${maxDepth}, minPoints=${minPoints}`); | |
| return this.buildNode(points, indices, 0, maxDepth, minPoints); | |
| } catch (error) { | |
| console.error('Error in BVH.build:', error); | |
| console.error('Error stack:', error.stack); | |
| throw error; | |
| } | |
| }, | |
| /** | |
| * Recursively build BVH node | |
| */ | |
| buildNode: function(points, indices, depth, maxDepth, minPoints) { | |
| // Calculate AABB for this node | |
| const bounds = this.calculateAABB(points, indices); | |
| const node = { | |
| bounds: bounds, | |
| indices: indices, | |
| depth: depth, | |
| left: null, | |
| right: null, | |
| isLeaf: false | |
| }; | |
| // Check termination conditions | |
| if (depth >= maxDepth || indices.length <= minPoints) { | |
| node.isLeaf = true; | |
| return node; | |
| } | |
| // Find longest axis and split | |
| const size = { | |
| x: bounds.max.x - bounds.min.x, | |
| y: bounds.max.y - bounds.min.y, | |
| z: bounds.max.z - bounds.min.z | |
| }; | |
| let axis = 0; // 0=x, 1=y, 2=z | |
| let maxSize = size.x; | |
| if (size.y > maxSize) { axis = 1; maxSize = size.y; } | |
| if (size.z > maxSize) { axis = 2; maxSize = size.z; } | |
| // Sort indices along chosen axis | |
| indices.sort((a, b) => { | |
| const aVal = points[a * 3 + axis]; | |
| const bVal = points[b * 3 + axis]; | |
| return aVal - bVal; | |
| }); | |
| // Split at median | |
| const mid = Math.floor(indices.length / 2); | |
| const leftIndices = indices.slice(0, mid); | |
| const rightIndices = indices.slice(mid); | |
| // Recursively build children | |
| if (leftIndices.length > 0) { | |
| node.left = this.buildNode(points, leftIndices, depth + 1, maxDepth, minPoints); | |
| } | |
| if (rightIndices.length > 0) { | |
| node.right = this.buildNode(points, rightIndices, depth + 1, maxDepth, minPoints); | |
| } | |
| return node; | |
| }, | |
| /** | |
| * Calculate axis-aligned bounding box for point indices | |
| */ | |
| calculateAABB: function(points, indices) { | |
| const bounds = { | |
| min: { x: Infinity, y: Infinity, z: Infinity }, | |
| max: { x: -Infinity, y: -Infinity, z: -Infinity } | |
| }; | |
| for (const idx of indices) { | |
| const i = idx * 3; | |
| const x = points[i]; | |
| const y = points[i + 1]; | |
| const z = points[i + 2]; | |
| bounds.min.x = Math.min(bounds.min.x, x); | |
| bounds.min.y = Math.min(bounds.min.y, y); | |
| bounds.min.z = Math.min(bounds.min.z, z); | |
| bounds.max.x = Math.max(bounds.max.x, x); | |
| bounds.max.y = Math.max(bounds.max.y, y); | |
| bounds.max.z = Math.max(bounds.max.z, z); | |
| } | |
| return bounds; | |
| }, | |
| /** | |
| * Count total nodes in tree (for instancing) | |
| */ | |
| countNodes: function(node) { | |
| if (!node) return 0; | |
| return 1 + this.countNodes(node.left) + this.countNodes(node.right); | |
| }, | |
| /** | |
| * Flatten tree into arrays for instanced rendering | |
| */ | |
| flattenTree: function(node, centers, sizes, colors, maxDepth, displayLevel = -1) { | |
| const nodes = []; | |
| const traverse = (n) => { | |
| if (!n) return; | |
| // Skip if filtering by specific level | |
| if (displayLevel >= 0 && n.depth !== displayLevel) { | |
| traverse(n.left); | |
| traverse(n.right); | |
| return; | |
| } | |
| // Calculate center and size | |
| const center = new THREE.Vector3( | |
| (n.bounds.min.x + n.bounds.max.x) / 2, | |
| (n.bounds.min.y + n.bounds.max.y) / 2, | |
| (n.bounds.min.z + n.bounds.max.z) / 2 | |
| ); | |
| const size = new THREE.Vector3( | |
| n.bounds.max.x - n.bounds.min.x, | |
| n.bounds.max.y - n.bounds.min.y, | |
| n.bounds.max.z - n.bounds.min.z | |
| ); | |
| // Color based on depth (rainbow gradient) | |
| const t = n.depth / Math.max(1, maxDepth); | |
| const color = new THREE.Color(); | |
| color.setHSL(t * 0.7, 0.8, 0.5); // 0 to 0.7 goes from red to blue | |
| nodes.push({ center, size, color }); | |
| traverse(n.left); | |
| traverse(n.right); | |
| }; | |
| traverse(node); | |
| return nodes; | |
| } | |
| }; | |
| /** | |
| * Create instanced wireframe boxes for BVH visualization | |
| * Uses custom shader to apply per-instance transforms | |
| */ | |
| function createInstancedBVHBoxes(bvhNodes) { | |
| try { | |
| const numBoxes = bvhNodes.length; | |
| if (numBoxes === 0) { | |
| console.warn('No BVH nodes to render'); | |
| return null; | |
| } | |
| console.log(`Creating instanced BVH boxes for ${numBoxes} nodes`); | |
| // Create unit cube edge geometry (12 edges) | |
| const edges = [ | |
| // Bottom face | |
| [-0.5, -0.5, -0.5], [0.5, -0.5, -0.5], | |
| [0.5, -0.5, -0.5], [0.5, -0.5, 0.5], | |
| [0.5, -0.5, 0.5], [-0.5, -0.5, 0.5], | |
| [-0.5, -0.5, 0.5], [-0.5, -0.5, -0.5], | |
| // Top face | |
| [-0.5, 0.5, -0.5], [0.5, 0.5, -0.5], | |
| [0.5, 0.5, -0.5], [0.5, 0.5, 0.5], | |
| [0.5, 0.5, 0.5], [-0.5, 0.5, 0.5], | |
| [-0.5, 0.5, 0.5], [-0.5, 0.5, -0.5], | |
| // Vertical edges | |
| [-0.5, -0.5, -0.5], [-0.5, 0.5, -0.5], | |
| [0.5, -0.5, -0.5], [0.5, 0.5, -0.5], | |
| [0.5, -0.5, 0.5], [0.5, 0.5, 0.5], | |
| [-0.5, -0.5, 0.5], [-0.5, 0.5, 0.5] | |
| ]; | |
| // Flatten edge vertices | |
| const positions = new Float32Array(edges.length * 3); | |
| for (let i = 0; i < edges.length; i++) { | |
| positions[i * 3] = edges[i][0]; | |
| positions[i * 3 + 1] = edges[i][1]; | |
| positions[i * 3 + 2] = edges[i][2]; | |
| } | |
| // Create instanced buffer geometry | |
| const geometry = new THREE.InstancedBufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| // Create instance attributes (one per box) | |
| const instanceCenters = new Float32Array(numBoxes * 3); | |
| const instanceSizes = new Float32Array(numBoxes * 3); | |
| const instanceColors = new Float32Array(numBoxes * 3); | |
| for (let i = 0; i < numBoxes; i++) { | |
| const node = bvhNodes[i]; | |
| if (!node || !node.center || !node.size || !node.color) { | |
| console.error(`Invalid BVH node at index ${i}:`, node); | |
| throw new Error(`Invalid BVH node at index ${i}`); | |
| } | |
| // Center | |
| instanceCenters[i * 3] = node.center.x; | |
| instanceCenters[i * 3 + 1] = node.center.y; | |
| instanceCenters[i * 3 + 2] = node.center.z; | |
| // Size (half-extents become full extents for scaling) | |
| instanceSizes[i * 3] = node.size.x; | |
| instanceSizes[i * 3 + 1] = node.size.y; | |
| instanceSizes[i * 3 + 2] = node.size.z; | |
| // Color | |
| instanceColors[i * 3] = node.color.r; | |
| instanceColors[i * 3 + 1] = node.color.g; | |
| instanceColors[i * 3 + 2] = node.color.b; | |
| } | |
| geometry.setAttribute('instanceCenter', new THREE.InstancedBufferAttribute(instanceCenters, 3)); | |
| geometry.setAttribute('instanceSize', new THREE.InstancedBufferAttribute(instanceSizes, 3)); | |
| geometry.setAttribute('instanceColor', new THREE.InstancedBufferAttribute(instanceColors, 3)); | |
| // Custom shader material for instanced lines | |
| const material = new THREE.ShaderMaterial({ | |
| vertexShader: ` | |
| attribute vec3 instanceCenter; | |
| attribute vec3 instanceSize; | |
| attribute vec3 instanceColor; | |
| varying vec3 vColor; | |
| void main() { | |
| vColor = instanceColor; | |
| // Apply instance transform: scale then translate | |
| vec3 transformed = position * instanceSize + instanceCenter; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| void main() { | |
| gl_FragColor = vec4(vColor, 1.0); | |
| } | |
| `, | |
| transparent: false, | |
| depthTest: true, | |
| depthWrite: true | |
| }); | |
| // Create line segments | |
| const lineSegments = new THREE.LineSegments(geometry, material); | |
| console.log(`Successfully created BVH line segments with ${numBoxes} instances`); | |
| return lineSegments; | |
| } catch (error) { | |
| console.error('Error in createInstancedBVHBoxes:', error); | |
| console.error('Error stack:', error.stack); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * 3D Hilbert curve implementation | |
| * Maps between 3D coordinates and 1D position along the curve | |
| */ | |
| const HilbertCurve3D = { | |
| // Rotation matrices for the 8 octants of the Hilbert curve | |
| rotations: [ | |
| [0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], | |
| [2, 0, 1], [2, 1, 0], [0, 1, 2], [0, 2, 1] | |
| ], | |
| // Convert 3D coordinates to Hilbert index | |
| coordsToIndex: function(x, y, z, order) { | |
| let index = 0; | |
| for (let i = order - 1; i >= 0; i--) { | |
| const bits = [ | |
| (x >> i) & 1, | |
| (y >> i) & 1, | |
| (z >> i) & 1 | |
| ]; | |
| // Calculate octant | |
| const octant = bits[0] * 4 + bits[1] * 2 + bits[2]; | |
| index = (index << 3) | octant; | |
| // Apply rotation for next level | |
| const temp = [bits[0], bits[1], bits[2]]; | |
| const rot = this.rotations[octant]; | |
| bits[0] = temp[rot[0]]; | |
| bits[1] = temp[rot[1]]; | |
| bits[2] = temp[rot[2]]; | |
| } | |
| return index; | |
| }, | |
| // Convert Hilbert index to 3D coordinates | |
| indexToCoords: function(index, order) { | |
| let x = 0, y = 0, z = 0; | |
| let octant = 0; | |
| for (let i = 0; i < order; i++) { | |
| octant = index & 7; | |
| index >>= 3; | |
| const rot = this.rotations[octant]; | |
| const bits = [ | |
| (octant >> 2) & 1, | |
| (octant >> 1) & 1, | |
| octant & 1 | |
| ]; | |
| // Apply inverse rotation | |
| x = (x << 1) | bits[rot[0]]; | |
| y = (y << 1) | bits[rot[1]]; | |
| z = (z << 1) | bits[rot[2]]; | |
| } | |
| return [x, y, z]; | |
| } | |
| }; | |
| /** | |
| * 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 | |
| } | |
| // BVH visualization modes | |
| if (projectionMode === 'bvh-with-points' || projectionMode === 'bvh-only') { | |
| try { | |
| const numPoints = points.length / 3; | |
| console.log(`Starting BVH projection mode: ${projectionMode}, numPoints: ${numPoints}`); | |
| // Get BVH parameters from UI | |
| const maxDepthElement = document.getElementById('bvhMaxDepth'); | |
| const minPointsElement = document.getElementById('bvhMinPoints'); | |
| const displayLevelElement = document.getElementById('bvhDisplayLevel'); | |
| const maxDepth = maxDepthElement ? parseInt(maxDepthElement.value) : 8; | |
| const minPoints = minPointsElement ? parseInt(minPointsElement.value) : 8; | |
| const displayLevel = displayLevelElement ? parseInt(displayLevelElement.value) : -1; | |
| console.log(`BVH parameters: maxDepth=${maxDepth}, minPoints=${minPoints}, displayLevel=${displayLevel}`); | |
| // Validate parameters | |
| if (isNaN(maxDepth) || maxDepth < 1 || maxDepth > 12) { | |
| throw new Error(`Invalid maxDepth: ${maxDepth}`); | |
| } | |
| if (isNaN(minPoints) || minPoints < 1) { | |
| throw new Error(`Invalid minPoints: ${minPoints}`); | |
| } | |
| if (numPoints === 0) { | |
| throw new Error('No points to build BVH'); | |
| } | |
| // Build BVH tree | |
| console.log('Building BVH tree...'); | |
| const bvhRoot = BVH.build(points, maxDepth, minPoints); | |
| console.log('BVH tree built successfully'); | |
| // Flatten tree for rendering | |
| console.log('Flattening BVH tree...'); | |
| const bvhNodes = BVH.flattenTree(bvhRoot, null, null, null, maxDepth, displayLevel); | |
| console.log(`BVH flattened: ${bvhNodes.length} boxes at ${displayLevel >= 0 ? 'level ' + displayLevel : 'all levels'}`); | |
| if (bvhNodes.length === 0) { | |
| console.warn('No BVH nodes generated - returning standard points'); | |
| return points; // Just return points as-is for standard handling | |
| } | |
| // Return special structure indicating BVH mode | |
| // We return the points as-is and attach bvhData which will be handled specially | |
| return { | |
| points: points, // Keep original points | |
| bvhData: { | |
| nodes: bvhNodes, | |
| showPoints: projectionMode === 'bvh-with-points' | |
| } | |
| }; | |
| } catch (error) { | |
| console.error('Error in BVH projection mode:', error); | |
| console.error('Error stack:', error.stack); | |
| console.error('Error details:', { | |
| message: error.message, | |
| name: error.name, | |
| projectionMode: projectionMode, | |
| pointsLength: points.length | |
| }); | |
| // Return standard points as fallback | |
| return points; | |
| } | |
| } | |
| // 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 | |
| }; | |
| } | |
| // Hilbert curve projection - rearrange points along a 3D Hilbert curve | |
| if (projectionMode === 'hilbert-curve') { | |
| const numPoints = points.length / 3; | |
| const order = Math.max(2, Math.min(8, Math.ceil(Math.log2(Math.cbrt(numPoints))))); // Adaptive order | |
| const gridSize = Math.pow(2, order); | |
| // Create array to store points with their Hilbert indices | |
| const indexedPoints = []; | |
| for (let i = 0; i < numPoints; i++) { | |
| const pointIndex = i * 3; | |
| const x = points[pointIndex]; | |
| const y = points[pointIndex + 1]; | |
| const z = points[pointIndex + 2]; | |
| // Convert normalized coordinates [-1,1] to discrete grid [0, gridSize-1] | |
| const discreteX = Math.max(0, Math.min(gridSize - 1, Math.floor((x + 1) / 2 * gridSize))); | |
| const discreteY = Math.max(0, Math.min(gridSize - 1, Math.floor((y + 1) / 2 * gridSize))); | |
| const discreteZ = Math.max(0, Math.min(gridSize - 1, Math.floor((z + 1) / 2 * gridSize))); | |
| // Calculate Hilbert index for this point | |
| const hilbertIndex = HilbertCurve3D.coordsToIndex(discreteX, discreteY, discreteZ, order); | |
| indexedPoints.push({ | |
| hilbertIndex: hilbertIndex, | |
| originalIndex: i, | |
| x: x, | |
| y: y, | |
| z: z | |
| }); | |
| } | |
| // Sort points by Hilbert index to create a continuous path | |
| indexedPoints.sort((a, b) => a.hilbertIndex - b.hilbertIndex); | |
| // Create new point array in Hilbert order | |
| const orderedPoints = new Float32Array(points.length); | |
| for (let i = 0; i < indexedPoints.length; i++) { | |
| const pt = indexedPoints[i]; | |
| orderedPoints[i * 3] = pt.x; | |
| orderedPoints[i * 3 + 1] = pt.y; | |
| orderedPoints[i * 3 + 2] = pt.z; | |
| } | |
| // Return with path data flag to draw connecting lines | |
| return { | |
| points: orderedPoints, | |
| pathData: true, | |
| numPoints: numPoints | |
| }; | |
| } | |
| // 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 (projectionResult && projectionResult.bvhData) { | |
| // BVH mode - return points, colors, and BVH data | |
| points = projectionResult.points; | |
| return { | |
| points, | |
| colors: colors.slice(0, pointIndex), | |
| numPoints: projectionResult.bvhData.showPoints ? (pointIndex / 3) : 0, | |
| bvhNodes: projectionResult.bvhData.nodes, | |
| bvhMode: true, | |
| showPoints: projectionResult.bvhData.showPoints | |
| }; | |
| } 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.contextLost = false; // Track WebGL context state | |
| this.init(); | |
| this.setupEventListeners(); | |
| this.setupDropZone(); | |
| this.setupPasteHandling(); | |
| this.setupVisibilityHandling(); | |
| 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 { | |
| // Extract all Points objects from point clouds (which may be Groups) | |
| const pointObjects = []; | |
| for (const cloudOrGroup of this.pointClouds) { | |
| if (cloudOrGroup.type === 'Points') { | |
| // Direct Points object (old style) | |
| pointObjects.push(cloudOrGroup); | |
| } else if (cloudOrGroup.type === 'Group') { | |
| // Group containing Points and possibly other objects | |
| cloudOrGroup.traverse((child) => { | |
| if (child.type === 'Points') { | |
| pointObjects.push(child); | |
| } | |
| }); | |
| } | |
| } | |
| if (pointObjects.length === 0) { | |
| console.error('No point objects found to export'); | |
| this._handleExportError('No points to export'); | |
| return; | |
| } | |
| // Count total number of vertices | |
| let totalVertices = 0; | |
| for (const pointObj of pointObjects) { | |
| if (pointObj.geometry && pointObj.geometry.attributes && pointObj.geometry.attributes.position) { | |
| totalVertices += pointObj.geometry.attributes.position.count; | |
| } else { | |
| console.warn('Point object missing geometry or position attribute:', pointObj); | |
| } | |
| } | |
| if (totalVertices === 0) { | |
| console.error('No vertices found in point objects'); | |
| this._handleExportError('No vertices to export'); | |
| return; | |
| } | |
| console.log(`Exporting ${totalVertices.toLocaleString()} vertices from ${pointObjects.length} point objects`); | |
| // 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 objects with smaller memory footprint | |
| const processPoints = (objIndex = 0) => { | |
| if (objIndex >= pointObjects.length) { | |
| // All objects processed, create final blob and download | |
| this._downloadBlobChunks(chunks, filename, totalVertices); | |
| return; | |
| } | |
| // Get current point object | |
| const pointObj = pointObjects[objIndex]; | |
| const positions = pointObj.geometry.attributes.position.array; | |
| const colors = pointObj.geometry.attributes.color.array; | |
| const count = pointObj.geometry.attributes.position.count; | |
| // Get the point object's world position (from parent group) | |
| let worldPos = new THREE.Vector3(); | |
| pointObj.getWorldPosition(worldPos); | |
| // Process points in very small chunks to avoid memory spikes | |
| const processPointsChunk = (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 objProgress = (endIdx / count * 100).toFixed(1); | |
| 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>Object ${objIndex+1}/${pointObjects.length}: ${objProgress}%</div>`; | |
| // Process next chunk or next object | |
| if (endIdx < count) { | |
| // Use shorter timeout for smaller chunks to maintain responsiveness | |
| setTimeout(() => processPointsChunk(endIdx), 5); | |
| } else { | |
| setTimeout(() => processPoints(objIndex + 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 object | |
| processPointsChunk(); | |
| }; | |
| // Start processing point objects | |
| setTimeout(() => processPoints(), 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() { | |
| try { | |
| // 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 with context loss handling | |
| this.renderer = new THREE.WebGLRenderer({ antialias: false }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.getElementById('container').appendChild(this.renderer.domElement); | |
| // Add WebGL context loss handlers | |
| const canvas = this.renderer.domElement; | |
| canvas.addEventListener('webglcontextlost', (event) => { | |
| event.preventDefault(); | |
| console.error('WebGL context lost! Attempting to recover...'); | |
| this.handleContextLost(); | |
| }, false); | |
| canvas.addEventListener('webglcontextrestored', () => { | |
| console.log('WebGL context restored! Reinitializing...'); | |
| this.handleContextRestored(); | |
| }, false); | |
| 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', () => { | |
| try { | |
| if (this.camera && this.renderer) { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| } catch (error) { | |
| console.error('Error in resize handler:', error); | |
| } | |
| }); | |
| console.log('Initialization complete'); | |
| } catch (error) { | |
| console.error('Error during initialization:', error); | |
| throw error; | |
| } | |
| } | |
| handleContextLost() { | |
| // Stop the animation loop | |
| this.contextLost = true; | |
| console.log('Context lost - animation loop paused'); | |
| } | |
| handleContextRestored() { | |
| // Reinitialize renderer | |
| try { | |
| this.contextLost = false; | |
| // Recreate the scene elements | |
| if (this.pointClouds.length > 0 || this.pathLines.length > 0) { | |
| console.log('Recreating scene after context restoration...'); | |
| // The geometries and materials should be recreated automatically by Three.js | |
| } | |
| console.log('Context restored - resuming animation loop'); | |
| } catch (error) { | |
| console.error('Error restoring context:', error); | |
| } | |
| } | |
| 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', () => { | |
| // Show/hide BVH controls based on mode | |
| const isBVHMode = projectionModeSelect.value === 'bvh-with-points' || | |
| projectionModeSelect.value === 'bvh-only'; | |
| document.getElementById('bvhControls').style.display = isBVHMode ? 'flex' : 'none'; | |
| document.getElementById('bvhMinPointsControl').style.display = isBVHMode ? 'flex' : 'none'; | |
| document.getElementById('bvhLevelControl').style.display = isBVHMode ? 'flex' : 'none'; | |
| // 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', | |
| 'bvhMaxDepth', 'bvhMinPoints', 'bvhDisplayLevel']; | |
| 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() { | |
| try { | |
| if (!this.camera) { | |
| console.error('Cannot reset camera - camera is null'); | |
| return; | |
| } | |
| new TWEEN.Tween(this.camera.position) | |
| .to({ | |
| x: 0, | |
| y: 0, | |
| z: this.cameraRadius | |
| }, 1000) | |
| .easing(TWEEN.Easing.Quadratic.InOut) | |
| .start(); | |
| this.cameraAngle = 0; | |
| } catch (error) { | |
| console.error('Error in resetCamera():', error); | |
| } | |
| } | |
| setupVisibilityHandling() { | |
| // Monitor page visibility to detect when tab becomes inactive | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.hidden) { | |
| console.log('Page hidden - idle state started at', new Date().toISOString()); | |
| } else { | |
| console.log('Page visible - resuming from idle at', new Date().toISOString()); | |
| // Check if renderer is still valid | |
| if (this.renderer) { | |
| const gl = this.renderer.getContext(); | |
| if (gl) { | |
| const contextLost = gl.isContextLost(); | |
| if (contextLost) { | |
| console.error('WebGL context was lost while page was hidden'); | |
| } else { | |
| console.log('WebGL context is still valid'); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| 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 === 'hilbert-curve') { | |
| loadingText += ` <span class="hilbert-indicator">(HILBERT)</span>`; | |
| } else if (projectionMode === 'bvh-with-points') { | |
| loadingText += ` <span class="bvh-indicator">(BVH+PTS)</span>`; | |
| } else if (projectionMode === 'bvh-only') { | |
| loadingText += ` <span class="bvh-indicator">(BVH)</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() { | |
| try { | |
| // Remove point clouds | |
| for (const cloud of this.pointClouds) { | |
| try { | |
| if (cloud && this.scene) { | |
| this.scene.remove(cloud); | |
| // Dispose geometry and material to prevent memory leaks | |
| if (cloud.geometry) cloud.geometry.dispose(); | |
| if (cloud.material) cloud.material.dispose(); | |
| } | |
| } catch (error) { | |
| console.error('Error removing point cloud:', error); | |
| } | |
| } | |
| this.pointClouds = []; | |
| // Remove path lines | |
| for (const line of this.pathLines) { | |
| try { | |
| if (line && this.scene) { | |
| this.scene.remove(line); | |
| // Dispose geometry and material to prevent memory leaks | |
| if (line.geometry) line.geometry.dispose(); | |
| if (line.material) line.material.dispose(); | |
| } | |
| } catch (error) { | |
| console.error('Error removing path line:', error); | |
| } | |
| } | |
| this.pathLines = []; | |
| this.totalPoints = 0; | |
| this.updateStatsDisplay(); | |
| } catch (error) { | |
| console.error('Error in clearPointClouds():', error); | |
| } | |
| } | |
| 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 === 'hilbert-curve') { | |
| statsText += ` <span class="hilbert-indicator">(HILBERT)</span>`; | |
| } else if (projectionMode === 'bvh-with-points') { | |
| statsText += ` <span class="bvh-indicator">(BVH+PTS)</span>`; | |
| } else if (projectionMode === 'bvh-only') { | |
| statsText += ` <span class="bvh-indicator">(BVH)</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 === 'hilbert-curve' ? ` • HILBERT` : | |
| projectionMode === 'bvh-with-points' ? ` • BVH+PTS` : | |
| projectionMode === 'bvh-only' ? ` • BVH` : | |
| 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, bvhNodes, bvhMode, showPoints } = processedData; | |
| // Create container group for this chunk | |
| const group = new THREE.Group(); | |
| group.position.set(x, y, z); | |
| // Handle BVH mode | |
| if (bvhMode && bvhNodes && bvhNodes.length > 0) { | |
| // Create instanced BVH boxes | |
| const bvhBoxes = createInstancedBVHBoxes(bvhNodes); | |
| if (bvhBoxes) { | |
| group.add(bvhBoxes); | |
| console.log(`Added ${bvhNodes.length} BVH boxes to scene`); | |
| } | |
| // Optionally add points if in "bvh-with-points" mode | |
| if (showPoints && numPoints > 0) { | |
| const pointCloud = this.createPointGeometry(points, colors, numPoints, pointSize); | |
| group.add(pointCloud); | |
| console.log(`Added ${numPoints} points with BVH boxes`); | |
| } | |
| // Return early - don't process as standard point cloud | |
| return group; | |
| } | |
| // Standard mode - create point cloud geometry | |
| if (numPoints > 0) { | |
| const pointCloud = this.createPointGeometry(points, colors, numPoints, pointSize); | |
| group.add(pointCloud); | |
| // If this is continuous path mode or Hilbert curve, also create line geometry | |
| if (pathData && numPoints > 1) { | |
| const actualPoints = points.slice(0, numPoints * 3); | |
| const actualColors = colors.slice(0, numPoints * 3); | |
| // 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); | |
| // Add to group | |
| group.add(pathLine); | |
| // Track path line separately for cleanup | |
| this.pathLines.push(pathLine); | |
| console.log(`Created path line with ${numPoints} connected points at position [${x}, ${y}, ${z}]`); | |
| } | |
| } | |
| return group; | |
| } | |
| createPointGeometry(points, colors, numPoints, pointSize) { | |
| // 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); | |
| return pointCloud; | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| try { | |
| // Skip rendering if context is lost | |
| if (this.contextLost) { | |
| return; | |
| } | |
| // Update TWEEN with error handling | |
| try { | |
| TWEEN.update(); | |
| } catch (tweenError) { | |
| console.error('Error in TWEEN.update():', tweenError); | |
| } | |
| // Update controls with error handling | |
| try { | |
| if (this.controls && this.controls.update) { | |
| this.controls.update(); | |
| } | |
| } catch (controlsError) { | |
| console.error('Error in controls.update():', controlsError); | |
| } | |
| // Render scene with error handling | |
| try { | |
| if (this.renderer && this.scene && this.camera) { | |
| this.renderer.render(this.scene, this.camera); | |
| } else { | |
| console.error('Missing required objects for rendering:', { | |
| renderer: !!this.renderer, | |
| scene: !!this.scene, | |
| camera: !!this.camera | |
| }); | |
| } | |
| } catch (renderError) { | |
| console.error('Error in renderer.render():', renderError); | |
| // Check if this is a context loss | |
| if (renderError.message && renderError.message.includes('context')) { | |
| console.error('Possible context loss detected in render error'); | |
| this.contextLost = true; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Critical error in animation loop:', error); | |
| console.error('Error stack:', error.stack); | |
| } | |
| } | |
| } | |
| // Global error handler to catch crashes | |
| window.addEventListener('error', (event) => { | |
| console.error('Global error caught:', { | |
| message: event.message, | |
| filename: event.filename, | |
| lineno: event.lineno, | |
| colno: event.colno, | |
| error: event.error | |
| }); | |
| // Log additional context | |
| console.error('Error occurred at:', new Date().toISOString()); | |
| console.error('Page visibility:', document.hidden ? 'hidden' : 'visible'); | |
| // Check if WebGL context might be related | |
| const canvas = document.querySelector('canvas'); | |
| if (canvas) { | |
| const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); | |
| if (gl) { | |
| console.error('WebGL context lost?', gl.isContextLost()); | |
| } | |
| } | |
| }); | |
| // Catch unhandled promise rejections | |
| window.addEventListener('unhandledrejection', (event) => { | |
| console.error('Unhandled promise rejection:', event.reason); | |
| console.error('Rejection type:', typeof event.reason); | |
| console.error('Rejection details:', JSON.stringify(event.reason, Object.getOwnPropertyNames(event.reason))); | |
| if (event.reason && event.reason.stack) { | |
| console.error('Error stack:', event.reason.stack); | |
| } | |
| console.error('Rejection occurred at:', new Date().toISOString()); | |
| // Try to get more info | |
| if (event.reason instanceof Error) { | |
| console.error('Error message:', event.reason.message); | |
| console.error('Error name:', event.reason.name); | |
| } | |
| }); | |
| // 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