js-subpcd
Version:
🌟 High-performance JavaScript/TypeScript QuadTree point cloud filtering and processing library. Published on npm as js-subpcd with PCL.js compatible API for spatial filtering, subsampling, and nearest neighbor search.
1,150 lines (968 loc) • 98.6 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2.5D Point Cloud QuadTree Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&family=Roboto+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
background: #1a1a1a;
color: white;
font-family: 'Roboto', sans-serif;
font-weight: normal;
overflow: hidden;
}
*, *::before, *::after {
color: white !important;
font-weight: normal !important;
}
canvas {
color: initial !important;
}
.log-area {
position: absolute;
top: 20px;
left: 20px;
z-index: 999;
width: 300px;
height: 200px;
font-family: 'Roboto Mono', monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
overflow: hidden;
pointer-events: none;
line-height: 1.2;
}
.progress-bar {
display: inline-block;
width: 200px;
height: 6px;
background-color: #2c3e50;
border-radius: 3px;
overflow: hidden;
margin: 0 8px;
vertical-align: middle;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #27ae60, #2ecc71);
border-radius: 3px;
transition: width 0.2s ease;
}
.progress-line {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.minimal-stats {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
font-family: 'Roboto Mono', monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
pointer-events: none;
}
.minimal-stats div {
margin: 2px 0;
}
#canvas-container {
width: 100vw;
height: 100vh;
}
.timer {
font-family: 'Roboto Mono', monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin: 2px 0;
pointer-events: none;
}
.controls-overlay {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 1000;
background: rgba(0, 0, 0, 0.8);
padding: 12px 16px;
border-radius: 8px;
font-family: 'Roboto Mono', monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
pointer-events: none;
backdrop-filter: blur(4px);
}
.controls-overlay .key {
color: #3498db;
font-weight: 500;
}
</style>
</head>
<body>
<div class="log-area" id="logArea"></div>
<div class="minimal-stats">
<div id="datasetDisplay">Mountain Range (500K)</div>
<div>Total: <span id="totalPoints">0</span></div>
<div>Displayed: <span id="displayedPoints">0</span></div>
<div>Grid: <span id="subsampleDisplay">No Sampling</span></div>
<div>FPS: <span id="fpsDisplay">60</span></div>
<div>Quality: <span id="qualityDisplay">100%</span></div>
</div>
<!-- Hidden select for dataset management -->
<select id="datasetSelect" style="display: none;">
<option value="mountain">Mountain Range (500K)</option>
<option value="alpine">Alpine Valley (750K)</option>
<option value="canyon">Grand Canyon (1M)</option>
<option value="synthetic">Synthetic Terrain (62.5K)</option>
<option value="lidar">LiDAR Sample (35K)</option>
</select>
<div class="controls-overlay">
<div><span class="key">A/D</span> Switch datasets</div>
<div><span class="key">W/S</span> More/Less points</div>
<div><span class="key">Space</span> Toggle rotation</div>
<div><span class="key">Mouse</span> Orbit camera</div>
<div><span class="key">Scroll</span> Zoom</div>
</div>
<div id="canvas-container"></div>
<script src="quadtree-wasm-wrapper.js"></script>
<script src="js-quadpcd-browser.js"></script>
<script>
// Global variables
let scene, camera, renderer, controls;
let currentPointCloud = null;
let quadTreeFilter = null;
let pointsMesh = null;
let originalPoints = [];
let filteredPoints = [];
let currentSubsampleLevel = 0; // Default to no sampling
let gridHelper = null;
let startTime = Date.now();
let timerInterval = null;
// Enhanced performance optimization variables
let lastFrameTime = 0;
let targetFrameTime = 16.67; // 60 FPS target (1000ms / 60fps)
let performanceMode = false;
let adaptiveQuality = true;
let renderLOD = 1.0; // Level of detail multiplier (0.1 = 10% of points)
let lastFPSCheck = 0;
let frameTimeHistory = [];
const MAX_FRAME_HISTORY = 60; // Track last 60 frames
// Web Worker for QuadTree operations
let quadTreeWorker = null;
let isWorkerReady = false;
// Timer control
let timerStarted = false;
// Enhanced loading state management
let isLoadingDataset = false;
let currentDatasetId = null; // Track current dataset to prevent race conditions
let pendingDatasetLoad = null; // Track pending dataset change
// Performance monitoring and memory management
let renderStats = {
frameCount: 0,
lastTime: performance.now(),
fps: 0
};
// Memory management tracking
let memoryStats = {
lastGC: Date.now(),
gcInterval: 30000, // Force GC every 30 seconds
geometryPool: [], // Pool for reusing geometries
materialPool: [], // Pool for reusing materials
maxPoolSize: 5
};
// Log area functionality
const logArea = document.getElementById('logArea');
const maxLogLines = 15;
let logLines = [];
function addLog(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
let logEntry;
if (type === 'progress' || type === 'complete') {
// For progress bars, don't add timestamp, use HTML directly
logEntry = message;
// Replace the last progress line if it exists
if (logLines.length > 0 && (logLines[logLines.length - 1].includes('progress-bar') || logLines[logLines.length - 1].includes('[â–ˆ'))) {
logLines[logLines.length - 1] = logEntry;
} else {
logLines.push(logEntry);
}
} else {
// Regular log entries with timestamp
logEntry = `[${timestamp}] ${message}`;
logLines.push(logEntry);
}
if (logLines.length > maxLogLines) {
logLines.shift();
}
logArea.innerHTML = logLines.join('<br>');
}
// Override console.log to capture messages
const originalConsoleLog = console.log;
console.log = function(...args) {
originalConsoleLog.apply(console, args);
addLog(args.join(' '));
};
// Add initial system info
addLog('Point Cloud QuadTree Demo initialized');
addLog(`Browser: ${navigator.userAgent.split(' ')[0]}`);
addLog(`WebGL: ${renderer ? 'Available' : 'Checking...'}`);
// Base subsampling levels - will be updated dynamically
let subsampleLevels = [
{ value: 2, display: '4x4' },
{ value: 3, display: '8x8' },
{ value: 4, display: '16x16' },
{ value: 5, display: '32x32' },
{ value: 6, display: '64x64' },
{ value: 7, display: '128x128' },
{ value: 8, display: '256x256' }
];
// Update subsampling levels based on point cloud size
function updateSubsamplingLevels(pointCount) {
console.log(`Updating subsampling levels for ${pointCount} points`);
if (pointCount < 10000) {
// Small datasets (< 10K points)
subsampleLevels = [
{ value: 0, display: 'No Sampling' },
{ value: 2, display: '4x4' },
{ value: 3, display: '8x8' },
{ value: 4, display: '16x16' },
{ value: 5, display: '32x32' },
{ value: 6, display: '64x64' }
];
currentSubsampleLevel = 0; // Default to no sampling for small datasets
} else if (pointCount < 100000) {
// Medium datasets (10K - 100K points)
subsampleLevels = [
{ value: 0, display: 'No Sampling' },
{ value: 3, display: '8x8' },
{ value: 4, display: '16x16' },
{ value: 5, display: '32x32' },
{ value: 6, display: '64x64' },
{ value: 7, display: '128x128' },
{ value: 8, display: '256x256' }
];
currentSubsampleLevel = 5; // Default to 32x32 for medium datasets
} else if (pointCount < 500000) {
// Large datasets (100K - 500K points)
subsampleLevels = [
{ value: 0, display: 'No Sampling' },
{ value: 4, display: '16x16' },
{ value: 5, display: '32x32' },
{ value: 6, display: '64x64' },
{ value: 7, display: '128x128' },
{ value: 8, display: '256x256' },
{ value: 9, display: '512x512' },
{ value: 10, display: '1024x1024' }
];
currentSubsampleLevel = 6; // Default to 64x64 for large datasets
} else {
// Very large datasets (500K+ points)
subsampleLevels = [
{ value: 0, display: 'No Sampling' },
{ value: 5, display: '32x32' },
{ value: 6, display: '64x64' },
{ value: 7, display: '128x128' },
{ value: 8, display: '256x256' },
{ value: 9, display: '512x512' },
{ value: 10, display: '1024x1024' },
{ value: 11, display: '2048x2048' }
];
currentSubsampleLevel = 6; // Default to 64x64 for good performance
}
console.log(`Subsampling levels updated:`, subsampleLevels.map(l => l.display));
console.log(`Default level set to: ${subsampleLevels.find(l => l.value === currentSubsampleLevel)?.display || 'N/A'}`);
// Update the UI to show the current level
updateSubsampleDisplay();
}
// Initialize Web Worker for QuadTree operations
function initializeWorker() {
if (window.Worker) {
try {
quadTreeWorker = new Worker('quadtree-worker.js');
quadTreeWorker.onmessage = function(e) {
const { type, data, error, points, processingTime, depth, gridSize, expectedMax, bounds, pointCount, message, progress } = e.data;
switch (type) {
case 'DATASET_LOADED':
console.log('Dataset loaded in worker thread');
originalPoints = points.map(p => new PointXYZ(p.x, p.y, p.z));
// Now build the tree with the loaded dataset
console.log('Building QuadTree in worker...');
showLoading('Building QuadTree in worker...');
// Calculate bounds for tree building
const coords = {
x: points.map(p => p.x),
y: points.map(p => p.y),
z: points.map(p => p.z)
};
let minX = coords.x[0], maxX = coords.x[0];
let minY = coords.y[0], maxY = coords.y[0];
for (let i = 1; i < coords.x.length; i++) {
if (coords.x[i] < minX) minX = coords.x[i];
if (coords.x[i] > maxX) maxX = coords.x[i];
}
for (let i = 1; i < coords.y.length; i++) {
if (coords.y[i] < minY) minY = coords.y[i];
if (coords.y[i] > maxY) maxY = coords.y[i];
}
const bounds = [minX, minY, maxX, maxY];
// Send to worker for tree building
quadTreeWorker.postMessage({
type: 'BUILD_TREE',
data: { coords, bounds }
});
break;
case 'TREE_BUILT':
console.log(`QuadTree built in worker: ${pointCount} points`);
isWorkerReady = true;
// Update loading message for final steps
loadingMessage = 'Finalizing visualization...';
loadingProgress = 90;
updateLoadingProgress();
// Update subsampling levels based on dataset size
updateSubsamplingLevels(pointCount);
updateSubsampleDisplay();
// Check if this is still the active dataset load
if (currentDatasetId !== datasetId) {
console.log(`Dataset ${datasetId} cancelled during processing`);
return;
}
// Apply initial subsampling (timer will start after first frame)
if (currentSubsampleLevel === 0) {
// No sampling - display all points
filteredPoints = originalPoints;
requestAnimationFrame(() => {
// Double-check we're still the active dataset
if (currentDatasetId === datasetId) {
displayPointCloudAsync(filteredPoints);
updateStats();
hideLoading(); // Complete loading
startTimerAfterFirstFrame();
isLoadingDataset = false; // Reset loading flag
currentDatasetId = null; // Clear current dataset ID
}
});
} else {
applySubsamplingWorker();
}
break;
case 'FILTER_COMPLETE':
console.log(`Worker filtering complete: depth ${depth} (${gridSize}x${gridSize}) -> ${points.length} points (expected max ${expectedMax}) in ${processingTime.toFixed(2)}ms`);
// Handle both initial loading and subsampling operations
filteredPoints = points.map(p => new PointXYZ(p.x, p.y, p.z));
// Schedule visualization update on next frame
requestAnimationFrame(() => {
displayPointCloudAsync(filteredPoints);
updateStats();
hideLoading();
// Only handle timer and loading state for initial dataset loading
if (currentDatasetId && isLoadingDataset) {
// Start timer after first visualization frame is rendered
startTimerAfterFirstFrame();
// Reset loading flag and clear dataset ID
isLoadingDataset = false;
currentDatasetId = null;
}
});
break;
case 'PROGRESS':
console.log(`Worker progress: ${message} (${progress}%)`);
break;
case 'ERROR':
console.error('Worker error:', error);
hideLoading();
// Fallback to main thread
console.log('Falling back to main thread processing...');
isWorkerReady = false;
// If we have original points, initialize main thread processing
if (originalPoints && originalPoints.length > 0) {
console.log('Initializing main thread processing with existing data...');
loadDatasetMainThread(originalPoints);
}
break;
}
};
quadTreeWorker.onerror = function(error) {
console.error('Worker error:', error);
isWorkerReady = false;
// Fallback to main thread
initializeMainThread();
// If we have original points, initialize main thread processing
if (originalPoints && originalPoints.length > 0) {
console.log('Initializing main thread processing with existing data...');
loadDatasetMainThread(originalPoints);
}
};
console.log('QuadTree Web Worker initialized');
isWorkerReady = true;
} catch (error) {
console.warn('Web Worker not supported, using main thread:', error);
initializeMainThread();
}
} else {
console.warn('Web Workers not supported, using main thread');
initializeMainThread();
}
}
// Fallback to main thread processing
function initializeMainThread() {
console.log('Using main thread for QuadTree operations');
isWorkerReady = false;
}
// Start timer after first visualization frame
function startTimerAfterFirstFrame() {
if (!timerStarted) {
if (!document.getElementById('timer')) {
startTimer();
} else {
resetTimer();
}
timerStarted = true;
console.log('Timer started after first visualization frame rendered');
}
}
// Legacy function for main thread fallback
function startTimerAfterTreeBuild() {
// Timer will start after visualization in main thread mode too
console.log('QuadTree construction completed - timer will start after visualization');
}
// Simple orbit controls implementation
class SimpleOrbitControls {
constructor(camera, domElement) {
this.camera = camera;
this.domElement = domElement;
this.target = new THREE.Vector3(0, 0, 0);
this.enableDamping = true;
this.dampingFactor = 0.05;
this.rotateSpeed = 1.0;
this.zoomSpeed = 1.2;
this.panSpeed = 0.8;
this.isMouseDown = false;
this.mouseX = 0;
this.mouseY = 0;
this.phi = Math.PI / 2; // Start with XY plane horizontal
this.theta = 0;
this.radius = this.camera.position.length();
// Auto-rotation properties
this.autoRotate = false;
this.autoRotateSpeed = 36; // degrees per second (10 seconds per revolution)
this.originalAutoRotateSpeed = 36; // Store original speed for reset
this.bindEvents();
this.update();
}
bindEvents() {
this.domElement.addEventListener('mousedown', (e) => {
this.isMouseDown = true;
this.mouseX = e.clientX;
this.mouseY = e.clientY;
});
this.domElement.addEventListener('mousemove', (e) => {
if (!this.isMouseDown) return;
const deltaX = e.clientX - this.mouseX;
const deltaY = e.clientY - this.mouseY;
this.theta -= deltaX * 0.01 * this.rotateSpeed;
this.phi += deltaY * 0.01 * this.rotateSpeed;
this.phi = Math.max(0.1, Math.min(Math.PI - 0.1, this.phi));
this.mouseX = e.clientX;
this.mouseY = e.clientY;
});
this.domElement.addEventListener('mouseup', () => {
this.isMouseDown = false;
});
this.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
this.radius *= (e.deltaY > 0) ? this.zoomSpeed : (1 / this.zoomSpeed);
this.radius = Math.max(1, Math.min(1000, this.radius));
});
}
update(deltaTime = 16.67) {
// Auto-rotation around vertical axis
if (this.autoRotate) {
// Convert degrees per second to radians per millisecond, then multiply by deltaTime
const radiansPerMs = (this.autoRotateSpeed * Math.PI / 180) / 1000;
this.theta += radiansPerMs * deltaTime;
}
// Ensure camera stays above zero surface (phi constraint)
this.phi = Math.max(0.1, Math.min(Math.PI / 2 - 0.1, this.phi));
// Use spherical coordinates with Z as up
const x = this.radius * Math.sin(this.phi) * Math.cos(this.theta);
const y = this.radius * Math.sin(this.phi) * Math.sin(this.theta);
const z = this.radius * Math.cos(this.phi);
this.camera.position.set(
this.target.x + x,
this.target.y + y,
this.target.z + z
);
// Set up vector to Z
this.camera.up.set(0, 0, 1);
this.camera.lookAt(this.target);
}
}
// Initialize 3D scene
function init3DScene() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
// Camera
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(50, 30, 50); // Position camera above the XY plane
camera.up.set(0, 0, 1); // Set Z as up direction
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('canvas-container').appendChild(renderer.domElement);
// Controls
controls = new SimpleOrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
// Lights
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 50);
scene.add(directionalLight);
// Grid will be created dynamically based on data bounds
// Start render loop
animate();
}
function animate() {
const now = performance.now();
const frameTime = now - lastFrameTime;
// Enhanced frame rate limiting with adaptive quality
if (frameTime < targetFrameTime) {
requestAnimationFrame(animate);
return;
}
lastFrameTime = now;
requestAnimationFrame(animate);
// Track frame time history for adaptive performance
frameTimeHistory.push(frameTime);
if (frameTimeHistory.length > MAX_FRAME_HISTORY) {
frameTimeHistory.shift();
}
// Performance monitoring and adaptive quality control
renderStats.frameCount++;
const deltaTime = now - renderStats.lastTime;
if (deltaTime >= 1000) {
renderStats.fps = Math.round((renderStats.frameCount * 1000) / deltaTime);
renderStats.frameCount = 0;
renderStats.lastTime = now;
// Adaptive performance optimization with smoother transitions
const avgFrameTime = frameTimeHistory.reduce((a, b) => a + b, 0) / frameTimeHistory.length;
const wasInPerformanceMode = performanceMode;
// More sophisticated performance mode detection
if (renderStats.fps < 30 || avgFrameTime > 20) {
if (!performanceMode) {
performanceMode = true;
adaptRenderQuality(0.7); // Reduce to 70% quality
console.log('Entered performance mode: reducing render quality');
}
} else if (renderStats.fps > 50 && avgFrameTime < 16) {
if (performanceMode || renderLOD < 1.0) {
performanceMode = false;
adaptRenderQuality(1.0); // Full quality
console.log('Exited performance mode: full render quality restored');
}
}
// Unified rotation speed
if (controls.autoRotate) {
controls.autoRotateSpeed = controls.originalAutoRotateSpeed;
}
// Memory management: Force garbage collection periodically
const currentTime = Date.now();
if (currentTime - memoryStats.lastGC > memoryStats.gcInterval) {
forceGarbageCollection();
memoryStats.lastGC = currentTime;
}
}
// Update controls and render
controls.update(frameTime);
renderer.render(scene, camera);
}
// Force garbage collection and cleanup
function forceGarbageCollection() {
try {
// Clear geometry pool if it gets too large
if (memoryStats.geometryPool.length > memoryStats.maxPoolSize) {
memoryStats.geometryPool.forEach(geom => geom.dispose());
memoryStats.geometryPool = [];
}
// Clear material pool if it gets too large
if (memoryStats.materialPool.length > memoryStats.maxPoolSize) {
memoryStats.materialPool.forEach(mat => {
if (mat.map) mat.map.dispose();
mat.dispose();
});
memoryStats.materialPool = [];
}
// Force garbage collection if available
if (window.gc) {
window.gc();
console.log('Forced garbage collection');
}
// Log memory usage if available
if (performance.memory) {
const used = Math.round(performance.memory.usedJSHeapSize / 1048576);
const total = Math.round(performance.memory.totalJSHeapSize / 1048576);
const limit = Math.round(performance.memory.jsHeapSizeLimit / 1048576);
console.log(`Memory: ${used}MB used / ${total}MB total / ${limit}MB limit`);
}
} catch (error) {
console.warn('Garbage collection failed:', error);
}
}
// Adaptive render quality function
function adaptRenderQuality(targetLOD) {
if (!pointsMesh || !filteredPoints) return;
renderLOD = targetLOD;
// If quality is reduced significantly, create a simplified version
if (renderLOD < 0.9 && filteredPoints.length > 50000) {
const step = Math.max(1, Math.floor(1 / renderLOD));
const simplifiedPoints = [];
for (let i = 0; i < filteredPoints.length; i += step) {
simplifiedPoints.push(filteredPoints[i]);
}
// Update geometry with simplified points
updatePointGeometry(simplifiedPoints);
console.log(`Render LOD: ${(renderLOD * 100).toFixed(0)}% (${simplifiedPoints.length}/${filteredPoints.length} points)`);
}
}
// Update point geometry without full recreation
function updatePointGeometry(points) {
if (!pointsMesh || !points || points.length === 0) return;
const positions = new Float32Array(points.length * 3);
const colors = new Float32Array(points.length * 3);
// Calculate color range once
let minZ = points[0].z, maxZ = points[0].z;
for (const point of points) {
minZ = Math.min(minZ, point.z);
maxZ = Math.max(maxZ, point.z);
}
const rangeZ = maxZ - minZ || 1;
// Fill arrays efficiently
for (let i = 0; i < points.length; i++) {
const point = points[i];
const idx = i * 3;
positions[idx] = point.x;
positions[idx + 1] = point.y;
positions[idx + 2] = point.z;
// Fast color calculation
const normalizedHeight = (point.z - minZ) / rangeZ;
const hue = (1 - normalizedHeight) * 0.67;
const color = new THREE.Color().setHSL(hue, 1.0, 0.5);
colors[idx] = color.r;
colors[idx + 1] = color.g;
colors[idx + 2] = color.b;
}
// Update attributes instead of recreating geometry
pointsMesh.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
pointsMesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
pointsMesh.geometry.attributes.position.needsUpdate = true;
pointsMesh.geometry.attributes.color.needsUpdate = true;
// Update draw count for LOD
pointsMesh.geometry.setDrawRange(0, points.length);
}
// Handle window resize
window.addEventListener('resize', function() {
if (camera && renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
});
// Reset timer function
function resetTimer() {
startTime = Date.now();
updateTimer(); // Update display immediately
}
// Reset timer state for new dataset
function resetTimerState() {
timerStarted = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
// Remove existing timer element
const existingTimer = document.getElementById('timer');
if (existingTimer) {
existingTimer.remove();
}
}
// Reset plot window state for new dataset
function resetPlotWindow() {
// Reset performance mode
performanceMode = false;
renderLOD = 1.0;
// Reset render stats
renderStats.fps = 60;
renderStats.frameCount = 0;
renderStats.lastTime = performance.now();
frameTimeHistory = [];
// Reset camera far plane to default
if (camera) {
camera.far = 10000;
camera.updateProjectionMatrix();
}
console.log('Plot window state reset for new dataset');
}
// Force cleanup of current loading operation
async function forceCleanupCurrentLoad() {
console.log('Force cleaning up current load operation...');
// Stop worker operations
if (quadTreeWorker && isWorkerReady) {
try {
// Send abort signal to worker
quadTreeWorker.postMessage({ type: 'ABORT' });
} catch (error) {
console.warn('Could not send abort to worker:', error);
}
}
// Clear loading progress
if (loadingProgressInterval) {
clearInterval(loadingProgressInterval);
loadingProgressInterval = null;
}
// Clear any build intervals
if (window.buildInterval) {
clearInterval(window.buildInterval);
window.buildInterval = null;
}
// Clear current point cloud
clearCurrentPointCloud();
// Small delay to ensure cleanup completes
await new Promise(resolve => setTimeout(resolve, 10));
}
// Clear current point cloud with object pooling
function clearCurrentPointCloud() {
if (pointsMesh) {
console.log('Clearing current point cloud from scene...');
scene.remove(pointsMesh);
// Pool geometry and material for reuse instead of disposing immediately
if (pointsMesh.geometry && memoryStats.geometryPool.length < memoryStats.maxPoolSize) {
memoryStats.geometryPool.push(pointsMesh.geometry);
} else if (pointsMesh.geometry) {
pointsMesh.geometry.dispose();
}
if (pointsMesh.material && memoryStats.materialPool.length < memoryStats.maxPoolSize) {
// Reset material state for reuse
if (pointsMesh.material.map) {
pointsMesh.material.map.dispose(); // Circle texture is small, dispose it
}
memoryStats.materialPool.push(pointsMesh.material);
} else if (pointsMesh.material) {
if (pointsMesh.material.map) {
pointsMesh.material.map.dispose();
}
pointsMesh.material.dispose();
}
pointsMesh = null;
}
// Also clear the grid helper
if (gridHelper) {
scene.remove(gridHelper);
gridHelper = null;
}
}
// Get geometry from pool or create new one
function getPooledGeometry() {
if (memoryStats.geometryPool.length > 0) {
const geometry = memoryStats.geometryPool.pop();
// Reset geometry attributes
geometry.attributes = {};
geometry.setDrawRange(0, Infinity);
console.log('Reusing pooled geometry');
return geometry;
}
return new THREE.BufferGeometry();
}
// Get material from pool or create new one
function getPooledMaterial(pointSize) {
if (memoryStats.materialPool.length > 0) {
const material = memoryStats.materialPool.pop();
// Reset material properties
material.size = pointSize;
material.map = createCircleTexture(); // Recreate texture
console.log('Reusing pooled material');
return material;
}
return new THREE.PointsMaterial({
size: pointSize,
vertexColors: true,
sizeAttenuation: false,
map: createCircleTexture(),
alphaTest: 0.1,
transparent: true
});
}
// Enhanced dataset loading with proper race condition handling
async function loadDataset() {
const datasetType = document.getElementById('datasetSelect').value;
const datasetId = `${datasetType}_${Date.now()}`; // Unique ID for this load operation
// Handle concurrent dataset load requests
if (isLoadingDataset) {
console.log('Dataset load in progress, cancelling and starting new load...');
// Mark current operation for cancellation
if (currentDatasetId) {
console.log(`Cancelling dataset load: ${currentDatasetId}`);
}
// Store pending request
pendingDatasetLoad = datasetId;
// Force cleanup of current operation
await forceCleanupCurrentLoad();
}
// Start new load operation
isLoadingDataset = true;
currentDatasetId = datasetId;
pendingDatasetLoad = null;
console.log(`Starting dataset load: ${datasetId} (${datasetType})`);
// Immediately clear existing point cloud to prevent overlap
clearCurrentPointCloud();
// Clear arrays to prevent stale data
originalPoints = [];
filteredPoints = [];
// Reset all state for new dataset
resetTimerState();
resetPlotWindow();
showLoading('Loading dataset...');
try {
// Load dataset points in main thread
let datasetPoints = [];
switch(datasetType) {
case 'mountain':
datasetPoints = await generateMountainRange();
break;
case 'alpine':
datasetPoints = await generateAlpineValley();
break;
case 'canyon':
datasetPoints = await generateGrandCanyon();
break;
case 'synthetic':
datasetPoints = await generateSyntheticTerrain();
break;
case 'lidar':
datasetPoints = await generateLidarSample();
break;
}
console.log(`Generated ${datasetPoints.length} points for ${datasetType} dataset`);
if (isWorkerReady && quadTreeWorker) {
// Use worker for QuadTree operations
console.log('Using worker for QuadTree construction...');
showLoading('Loading dataset in worker...');
// Store original points
originalPoints = datasetPoints;
// First load dataset in worker to initialize QuadTreeFilter
quadTreeWorker.postMessage({
type: 'LOAD_DATASET',
data: {
points: datasetPoints.map(p => ({ x: p.x, y: p.y, z: p.z })),
datasetType: datasetType
}
});
// Enable controls immediately
updateSubsampleDisplay();
} else {
// Fallback to main thread
await loadDatasetMainThread(datasetPoints);
}
} catch (error) {
console.error(`Error loading dataset ${currentDatasetId}:`, error);
if (currentDatasetId === datasetId) { // Only handle error if this is still the current operation
hideLoading();
isLoadingDataset = false;
currentDatasetId = null;
}
}
// Check if there's a pending dataset load after completion
if (pendingDatasetLoad && pendingDatasetLoad !== datasetId) {
console.log(`Processing pending dataset load: ${pendingDatasetLoad}`);
setTimeout(() => loadDataset(), 50); // Small delay before loading pending dataset
}
}
// Fallback main thread dataset loading
async function loadDatasetMainThread(datasetPoints) {
showLoading('Initializing dataset...');
// Initialize dataset
await initializeDataset(datasetPoints);
// Update loading message for QuadTree building
loadingMessage = 'Building QuadTree...';
console.log('Auto-building QuadTree for caching...');
const coords = quadTreeFilter.dataLoader.getCoordinateArrays();
const bounds = quadTreeFilter.dataLoader.getBounds();
// Build tree with non-blocking operation and progress simulation
await new Promise(resolve => {
let buildProgress = 0;
window.buildInterval = setInterval(() => {
buildProgress += 10;
if (buildProgress <= 90) {
loadingProgress = buildProgress;
updateLoadingProgress();
}
}, 50);
setTimeout(() => {
if (window.buildInterval) {
clearInterval(window.buildInterval);
window.buildInterval = null;
}
quadTreeFilter.quadtreeManager.buildTreeFromArrays(coords.x, coords.y, coords.z, bounds);
quadTreeFilter.bounds = bounds;
quadTreeFilter._treeBuilt = true;
loadingProgress = 100;
updateLoadingProgress();
console.log(`QuadTree built and cached with ${coords.x.length} points`);
resolve();
}, 500); // Simulate some build time
});
startTimerAfterTreeBuild();
// Update subsampling levels based on dataset size
updateSubsamplingLevels(coords.x.length);
// Update subsampling display
updateSubsampleDisplay();
// Apply initial subsampling or show all points
if (currentSubsampleLevel === 0) {
// No sampling - display all points
filteredPoints = originalPoints;
} else {
await applySampling();
}
// Display filtered points and start timer after first frame
await displayPointCloud(filteredPoints);
updateStats();
// Start timer after main thread visualization
requestAnimationFrame(() => {
startTimerAfterFirstFrame();
});
hideLoading();
isLoadingDataset = false;
}
// Generate Mountain Range (500K points) - Optimized
async function generateMountainRange() {
console.log('Generating Mountain Range dataset (500K points)...');
const points = [];
const width = 800;
const height = 625;
const extent = 2000; // 2km range
const totalPoints = width * height;
// Pre-calculate common values
const invWidth = 1 / width;
const invHeight = 1 / height;
const halfExtent = extent * 0.5;
console.log(`[DEBUG] Mountain Range generation:`);
console.log(` - Grid: ${width}x${height} = ${totalPoints} points`);
console.log(` - Extent: ${extent} (${-extent/2} to ${extent/2})`);
console.log(` - Expected X range: ${(0 * invWidth - 0.5) * extent} to ${((width-1) * invWidth - 0.5) * extent}`);
console.log(` - Expected Y range: ${(0 * invHeight - 0.5) * extent} to ${((height-1) * invHeight - 0.5) * extent}`);
// Batch processing for better performance
const batchSize = 5000;
let processedPoints = 0;
for (let x = 0; x < width; x++) {
const worldXBase = (x * invWidth - 0.5) * extent;
const batchPoints = [];
for (let y = 0; y < height; y++) {
const worldX = worldXBase;
const worldY = (y * invHeight - 0.5) * extent;