Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active August 14, 2025 19:53
Show Gist options
  • Save lardratboy/01e2b1d9e3ab2af86ce230034841cc72 to your computer and use it in GitHub Desktop.
Save lardratboy/01e2b1d9e3ab2af86ce230034841cc72 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quantized Mirror Cloud - PLY Exporter</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #111;
color: #fff;
}
#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-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);
}
#controls {
position: absolute;
top: 10px;
left: 10px;
padding: 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(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 100;
width: 280px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
#controls.minimized {
transform: scale(0.1);
opacity: 0;
pointer-events: none;
}
#controls h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #4fc3f7;
text-align: center;
border-bottom: 1px solid rgba(79, 195, 247, 0.3);
padding-bottom: 8px;
position: relative;
}
#minimizeButton {
position: absolute;
top: -2px;
right: 0;
background: none;
border: none;
color: #4fc3f7;
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: background-color 0.2s ease;
}
#minimizeButton:hover {
background-color: rgba(79, 195, 247, 0.2);
}
#restoreButton {
position: absolute;
top: 10px;
left: 10px;
background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(40, 40, 40, 0.9));
border: 1px solid rgba(79, 195, 247, 0.5);
border-radius: 50%;
color: #4fc3f7;
font-size: 18px;
cursor: pointer;
padding: 10px;
width: 44px;
height: 44px;
display: none;
align-items: center;
justify-content: center;
z-index: 101;
transition: all 0.3s ease;
}
#restoreButton:hover {
background: linear-gradient(145deg, rgba(79, 195, 247, 0.2), rgba(79, 195, 247, 0.1));
transform: scale(1.05);
}
.control-group {
margin-bottom: 12px;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border-left: 3px solid #4fc3f7;
}
.control-group h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: #4fc3f7;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.control-row:last-child {
margin-bottom: 0;
}
.control-row label {
font-size: 12px;
color: #ccc;
margin: 0;
width: 80px;
min-width: 80px;
flex-shrink: 0;
}
.control-row input, .control-row select {
flex: 1;
width: 0;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 12px;
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: 6px;
border: 2px dashed rgba(79, 195, 247, 0.5);
border-radius: 6px;
background: rgba(79, 195, 247, 0.1);
color: #fff;
font-size: 12px;
cursor: pointer;
margin-bottom: 10px;
transition: all 0.2s ease;
}
#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: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
margin-bottom: 6px;
}
button:hover:not(:disabled) {
background: linear-gradient(145deg, #1565c0, #0d47a1);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(21, 101, 192, 0.3);
}
button:disabled {
background: linear-gradient(145deg, #424242, #212121);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.button-group {
display: flex;
gap: 6px;
}
.button-group button {
flex: 1;
margin-bottom: 0;
}
#loadButton.highlight {
background: linear-gradient(145deg, #ff9800, #f57c00) !important;
animation: pulse 2s infinite;
}
@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, #stats {
font-size: 11px;
color: #999;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-bottom: 8px;
border-left: 3px solid #4fc3f7;
word-break: break-all;
line-height: 1.3;
}
#fileInfo strong, #stats 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: 102;
font-size: 14px;
color: #4fc3f7;
text-align: center;
min-width: 300px;
}
#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: 103;
width: 300px;
}
#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;
}
#exportInfo {
font-size: 12px;
color: #999;
margin-bottom: 15px;
text-align: center;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
</style>
</head>
<body>
<div id="container"></div>
<div id="dropZone">
<div class="drop-content">
<div style="font-size: 48px; margin-bottom: 16px; color: #4fc3f7;">📁</div>
<div style="font-size: 24px; margin-bottom: 8px; color: #4fc3f7;">Drop file here</div>
<div style="font-size: 14px; color: #ccc; opacity: 0.8;">Binary data files</div>
</div>
</div>
<div id="controls">
<h3>
🎯 Mirror Cloud Exporter
<button id="minimizeButton" title="Minimize controls">−</button>
</h3>
<input type="file" id="fileInput">
<div id="fileInfo"></div>
<div id="stats"></div>
<div class="control-group">
<h4>Data Input</h4>
<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>
</select>
</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>
<div class="control-group">
<h4>Quantization</h4>
<div class="control-row">
<label for="quantizationBits">Q-Bits:</label>
<input type="number" id="quantizationBits" min="2" max="10" value="8">
</div>
</div>
<div class="control-group">
<h4>Mirror Layout</h4>
<div class="control-row">
<label for="latticeSize">Grid Size:</label>
<input type="number" id="latticeSize" min="1" max="5" value="1">
</div>
</div>
<div class="control-group">
<h4>Display</h4>
<div class="control-row">
<label for="pointSize">Point Size:</label>
<input type="range" id="pointSize" min="0.5" max="10" step="0.5" value="3">
<span id="pointSizeValue">3.0</span>
</div>
</div>
<button id="loadButton" disabled>🔄 Load Point Cloud</button>
<button id="resetButton">🎯 Reset View</button>
<div class="button-group">
<button id="exportPlyButton" disabled>💾 Export PLY</button>
</div>
</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 Mirror Cloud to PLY</h3>
<div id="exportInfo">
This will export all mirrored instances and lattice copies as individual points.
</div>
<label for="exportFilename" style="display: block; margin-bottom: 8px; font-size: 12px;">Filename:</label>
<input type="text" id="exportFilename" placeholder="Enter filename">
<div class="buttons">
<button id="cancelExport">❌ Cancel</button>
<button id="confirmExport">✅ Export PLY</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// Data type configuration constants
const DATA_TYPES = {
int8: { size: 1, min: -128, max: 127, method: 'getInt8' },
uint8: { size: 1, min: 0, max: 255, method: 'getUint8' },
int16: { size: 2, min: -32768, max: 32767, method: 'getInt16' },
uint16: { size: 2, min: 0, max: 65535, method: 'getUint16' },
int32: { size: 4, min: -2147483648, max: 2147483647, method: 'getInt32' },
uint32: { size: 4, min: 0, max: 4294967295, method: 'getUint32' }
};
// Pre-calculate normalization multipliers for each data type
const NORMALIZERS = Object.fromEntries(
Object.entries(DATA_TYPES).map(([type, config]) => [
type,
{
multiplier: 2 / (config.max - config.min),
offset: config.min
}
])
);
/**
* Process binary data into normalized 3D points with colors using configurable quantization
*/
function quantizeProcessDataAs(buffer, dataType, isLittleEndian, quantizationBits = 8) {
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(', ')}`);
}
if (!Number.isInteger(quantizationBits) || quantizationBits < 2 || quantizationBits > 10) {
throw new Error('quantizationBits must be an integer between 2 and 10');
}
const totalBits = quantizationBits * 3;
if (totalBits > 30) {
throw new Error(`Total quantization bits (${totalBits}) would exceed safe integer limits. Max 30 bits (10 bits per dimension)`);
}
const typeSize = config.size;
const tupleSize = typeSize * 3;
if (buffer.byteLength < tupleSize) {
throw new Error(`Buffer too small for data type ${dataType}. Need at least ${tupleSize} bytes, got ${buffer.byteLength}`);
}
const view = new DataView(buffer);
const maxOffset = buffer.byteLength - tupleSize;
const maxTuples = Math.floor(buffer.byteLength / tupleSize);
const points = new Float32Array(maxTuples * 3);
const colors = new Float32Array(maxTuples * 3);
const { multiplier, offset } = NORMALIZERS[dataType];
const readMethod = view[config.method].bind(view);
const normalize = value => ((value - offset) * multiplier) - 1;
let pointIndex = 0;
let baseOffset = 0;
const qRange = 1 << quantizationBits;
const qHalfRange = qRange / 2;
const qMaxIndex = qRange - 1;
const totalQuantizedPositions = qRange * qRange * qRange;
const bitArraySizeInUint32 = Math.ceil(totalQuantizedPositions / 32);
const tupleBitArray = new Uint32Array(bitArraySizeInUint32);
const yShift = quantizationBits;
const zShift = quantizationBits * 2;
try {
while (baseOffset <= maxOffset) {
const x = normalize(readMethod(baseOffset, isLittleEndian));
const y = normalize(readMethod(baseOffset + typeSize, isLittleEndian));
const z = normalize(readMethod(baseOffset + typeSize * 2, isLittleEndian));
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)));
const qIndex = (qz << zShift) | (qy << yShift) | qx;
const elementIndex = qIndex >>> 5;
const bitPosition = qIndex & 0x1F;
const mask = 1 << bitPosition;
if ((tupleBitArray[elementIndex] & mask) === 0) {
tupleBitArray[elementIndex] |= mask;
points[pointIndex] = x;
points[pointIndex + 1] = y;
points[pointIndex + 2] = z;
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);
}
const actualPoints = new Float32Array(points.buffer, 0, pointIndex);
const actualColors = new Float32Array(colors.buffer, 0, pointIndex);
console.log(`Quantization completed: ${quantizationBits} bits, ${qRange}^3 possible positions, ${pointIndex / 3} unique points found`);
return {
points: actualPoints,
colors: actualColors,
numPoints: pointIndex / 3,
quantizationBits: quantizationBits,
quantizationRange: qRange
};
}
// Custom instanced point material with mirror transformations
class InstancedMirrorPointMaterial extends THREE.ShaderMaterial {
constructor() {
super({
uniforms: {
pointSize: { value: 3.0 }
},
vertexShader: `
uniform float pointSize;
attribute vec3 instanceMirror;
attribute vec3 instanceOffset;
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
// Apply mirror transformation
vec3 transformedPosition = position * instanceMirror;
// Apply lattice offset
transformedPosition += instanceOffset;
vec4 mvPosition = modelViewMatrix * vec4(transformedPosition, 1.0);
gl_Position = projectionMatrix * mvPosition;
// Proper point size with distance attenuation
float distance = length(mvPosition.xyz);
gl_PointSize = pointSize * (1.0 / (1.0 + 0.1 * distance));
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
// Create circular points
vec2 center = gl_PointCoord - vec2(0.5);
if (length(center) > 0.5) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
transparent: false
});
}
}
// Main Application
class QuantizedMirrorPLYExporter {
constructor() {
this.scene = null;
this.camera = null;
this.renderer = null;
this.instancedPointClouds = [];
this.autoRotate = true;
this.rotationSpeed = 0.5;
this.lastTime = 0;
this.latticeSize = 1;
this.originalFileName = '';
this.fileBuffer = null;
this.processedData = null; // Store the original quantized data
this.scaledPoints = null; // Store the scaled points used for display and export
this.scaledColors = null; // Store the scaled colors
this.scaleInfo = null; // Store scaling information for reference
this.controlsMinimized = false;
this.init();
this.setupEventListeners();
this.setupDropZone();
this.animate();
}
init() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x111111);
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.set(2, 2, 2);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('container').appendChild(this.renderer.domElement);
const axisHelper = new THREE.AxesHelper(1.5);
this.scene.add(axisHelper);
const gridHelper = new THREE.GridHelper(2, 20);
gridHelper.position.y = -0.01;
this.scene.add(gridHelper);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 2, 1);
this.scene.add(directionalLight);
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
}
setupDropZone() {
const dropZone = document.getElementById('dropZone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
document.addEventListener('dragenter', (e) => {
dropZone.style.display = 'flex';
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('dragleave', (e) => {
if (!dropZone.contains(e.relatedTarget)) {
dropZone.style.display = 'none';
dropZone.classList.remove('dragover');
}
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.display = 'none';
dropZone.classList.remove('dragover');
this.handleFiles(e.dataTransfer.files);
});
}
handleFiles(files) {
if (!files || files.length === 0) return;
const file = files[0];
this.originalFileName = file.name;
const fileInfoDiv = document.getElementById('fileInfo');
fileInfoDiv.innerHTML = `
<strong>File:</strong> ${file.name}<br>
<strong>Size:</strong> ${(file.size / (1024 * 1024)).toFixed(2)} MB
`;
const reader = new FileReader();
reader.onload = (e) => {
this.fileBuffer = e.target.result;
document.getElementById('loadButton').disabled = false;
document.getElementById('loadButton').classList.add('highlight');
document.getElementById('exportPlyButton').disabled = true;
console.log('File loaded successfully:', file.name);
};
reader.readAsArrayBuffer(file);
}
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() {
const dialog = document.getElementById('exportDialog');
const filenameInput = document.getElementById('exportFilename');
const exportInfo = document.getElementById('exportInfo');
// Calculate total points for export
const originalPoints = this.processedData ? this.processedData.numPoints : 0;
const totalInstances = 8 * Math.pow(this.latticeSize, 3);
const totalExportPoints = originalPoints * totalInstances;
// Generate default filename
const baseFilename = this.originalFileName.split('.')[0] || 'pointcloud';
filenameInput.value = `${baseFilename}_mirror_${this.latticeSize}x${this.latticeSize}x${this.latticeSize}`;
// Update export info
exportInfo.innerHTML = `
<strong>Export Details:</strong><br>
Original points: ${originalPoints.toLocaleString()}<br>
Mirror instances: 8 (±X, ±Y, ±Z combinations)<br>
Lattice cells: ${this.latticeSize}³ = ${Math.pow(this.latticeSize, 3)}<br>
<strong>Total export points: ${totalExportPoints.toLocaleString()}</strong>
`;
dialog.style.display = 'block';
}
hideExportDialog() {
document.getElementById('exportDialog').style.display = 'none';
}
async exportToPLY() {
if (!this.scaledPoints || !this.scaledColors) {
console.log('No processed data available for export');
return;
}
const filenameInput = document.getElementById('exportFilename');
let filename = filenameInput.value.trim();
if (!filename) {
filename = 'mirror_pointcloud';
}
if (!filename.toLowerCase().endsWith('.ply')) {
filename += '.ply';
}
this.hideExportDialog();
// Show loading message
const loadingMsg = document.getElementById('loadingMessage');
loadingMsg.style.display = 'block';
try {
await this.generatePLYFile(filename);
} catch (error) {
console.error('Export failed:', error);
loadingMsg.innerHTML = `
<div style="color: #ff6b6b;">❌ Export Failed</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">${error.message}</div>
`;
setTimeout(() => {
loadingMsg.style.display = 'none';
}, 5000);
}
}
async generatePLYFile(filename) {
// Use the scaled points and colors that match what's displayed
const points = this.scaledPoints;
const colors = this.scaledColors;
const numPoints = this.processedData.numPoints;
const n = this.latticeSize;
const totalInstances = 8 * Math.pow(n, 3);
const totalVertices = numPoints * totalInstances;
console.log(`Exporting ${numPoints} scaled points × ${totalInstances} instances = ${totalVertices.toLocaleString()} total points`);
// Mirror transformations (8 combinations of ±1 for each axis)
const mirrors = [
[1, 1, 1], // Original
[-1, 1, 1], // Mirror X
[1, -1, 1], // Mirror Y
[1, 1, -1], // Mirror Z
[-1, -1, 1], // Mirror X,Y
[-1, 1, -1], // Mirror X,Z
[1, -1, -1], // Mirror Y,Z
[-1, -1, -1] // Mirror X,Y,Z
];
// Create PLY header
const header = [
'ply',
'format ascii 1.0',
'comment Created by Quantized Mirror Point Cloud Exporter',
`comment Original points: ${numPoints.toLocaleString()}`,
`comment Mirror instances: 8`,
`comment Lattice size: ${n}x${n}x${n}`,
`comment Quantization: ${this.processedData.quantizationBits} bits`,
`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';
const chunks = [new Blob([header], { type: 'text/plain' })];
const loadingMsg = document.getElementById('loadingMessage');
// Process in chunks to avoid memory issues
const chunkSize = Math.min(10000, Math.floor(numPoints / 10) + 1);
let processedVertices = 0;
const offset = (n - 1) / 2;
loadingMsg.innerHTML = `
<div>📝 Generating PLY file...</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">
Processing ${totalVertices.toLocaleString()} vertices...
</div>
`;
// Process lattice cells
for (let lx = 0; lx < n; lx++) {
for (let ly = 0; ly < n; ly++) {
for (let lz = 0; lz < n; lz++) {
const latticeOffsetX = (lx - offset) * 2;
const latticeOffsetY = (ly - offset) * 2;
const latticeOffsetZ = (lz - offset) * 2;
// Process mirror transformations
for (let m = 0; m < 8; m++) {
const mirror = mirrors[m];
// Process original points in chunks
for (let startIdx = 0; startIdx < numPoints; startIdx += chunkSize) {
const endIdx = Math.min(startIdx + chunkSize, numPoints);
const lines = [];
for (let i = startIdx; i < endIdx; i++) {
const baseIdx = i * 3;
// Apply mirror transformation to scaled points (same as shader)
const x = points[baseIdx] * mirror[0] + latticeOffsetX;
const y = points[baseIdx + 1] * mirror[1] + latticeOffsetY;
const z = points[baseIdx + 2] * mirror[2] + latticeOffsetZ;
// Convert colors from [0,1] to [0,255]
const r = Math.floor(colors[baseIdx] * 255);
const g = Math.floor(colors[baseIdx + 1] * 255);
const b = Math.floor(colors[baseIdx + 2] * 255);
lines.push(`${x.toFixed(6)} ${y.toFixed(6)} ${z.toFixed(6)} ${r} ${g} ${b}`);
}
chunks.push(new Blob([lines.join('\n') + '\n'], { type: 'text/plain' }));
processedVertices += (endIdx - startIdx);
// Update progress
const progress = (processedVertices / totalVertices * 100).toFixed(1);
loadingMsg.innerHTML = `
<div>📝 Generating PLY file...</div>
<div style="font-size: 11px; margin-top: 8px; opacity: 0.8;">
${progress}% (${processedVertices.toLocaleString()}/${totalVertices.toLocaleString()})<br>
Lattice: [${lx},${ly},${lz}] Mirror: ${m+1}/8
</div>
`;
// Yield control to prevent UI freezing
if (processedVertices % 50000 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
}
}
}
}
// Create final blob and download
loadingMsg.innerHTML = `
<div>📝 Finalizing file...</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">Creating download...</div>
`;
const finalBlob = new Blob(chunks, { type: 'text/plain' });
const url = URL.createObjectURL(finalBlob);
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);
setTimeout(() => URL.revokeObjectURL(url), 1000);
loadingMsg.style.display = 'none';
console.log(`Successfully exported ${totalVertices.toLocaleString()} points to PLY file: ${filename}`);
}
setupEventListeners() {
const fileInput = document.getElementById('fileInput');
const loadButton = document.getElementById('loadButton');
const resetButton = document.getElementById('resetButton');
const exportPlyButton = document.getElementById('exportPlyButton');
const minimizeButton = document.getElementById('minimizeButton');
const restoreButton = document.getElementById('restoreButton');
const cancelExport = document.getElementById('cancelExport');
const confirmExport = document.getElementById('confirmExport');
// Control panel toggle
minimizeButton.addEventListener('click', () => this.toggleControls());
restoreButton.addEventListener('click', () => this.toggleControls());
// File handling
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files));
fileInput.addEventListener('click', (e) => { e.target.value = ''; }); // Reset to allow same file
// Main actions
loadButton.addEventListener('click', () => {
if (!this.fileBuffer) return;
this.loadPointCloud();
});
resetButton.addEventListener('click', () => this.resetCamera());
exportPlyButton.addEventListener('click', () => this.showExportDialog());
// Export dialog
cancelExport.addEventListener('click', () => this.hideExportDialog());
confirmExport.addEventListener('click', () => this.exportToPLY());
// Point size control
const pointSizeSlider = document.getElementById('pointSize');
const pointSizeValue = document.getElementById('pointSizeValue');
pointSizeSlider.addEventListener('input', (e) => {
const size = parseFloat(e.target.value);
pointSizeValue.textContent = size.toFixed(1);
this.instancedPointClouds.forEach(pc => {
if (pc.material && pc.material.uniforms && pc.material.uniforms.pointSize) {
pc.material.uniforms.pointSize.value = size;
}
});
});
// Mouse controls for camera
this.setupMouseControls();
}
setupMouseControls() {
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
const container = document.getElementById('container');
container.addEventListener('mousedown', (e) => {
isDragging = true;
this.autoRotate = false;
previousMousePosition = { x: e.clientX, y: e.clientY };
});
container.addEventListener('mousemove', (e) => {
if (isDragging) {
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
const rotationQuaternion = new THREE.Quaternion();
rotationQuaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), -deltaX * 0.01);
this.camera.position.applyQuaternion(rotationQuaternion);
const cameraRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
rotationQuaternion.setFromAxisAngle(cameraRight, -deltaY * 0.01);
this.camera.position.applyQuaternion(rotationQuaternion);
this.camera.lookAt(0, 0, 0);
previousMousePosition = { x: e.clientX, y: e.clientY };
}
});
container.addEventListener('mouseup', () => {
isDragging = false;
});
container.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 1.1 : 0.9;
this.camera.position.multiplyScalar(delta);
this.camera.lookAt(0, 0, 0);
});
container.addEventListener('dblclick', () => {
this.autoRotate = !this.autoRotate;
});
}
resetCamera() {
const cameraDistance = this.latticeSize * 2;
this.camera.position.set(cameraDistance, cameraDistance, cameraDistance);
this.camera.lookAt(0, 0, 0);
this.autoRotate = true;
}
loadPointCloud() {
document.getElementById('loadButton').classList.remove('highlight');
const dataType = document.getElementById('dataType').value;
const isLittleEndian = document.getElementById('endianness').value === 'true';
const quantizationBits = parseInt(document.getElementById('quantizationBits').value, 10) || 8;
this.latticeSize = parseInt(document.getElementById('latticeSize').value, 10) || 1;
const clampedQuantization = Math.max(2, Math.min(10, quantizationBits));
this.latticeSize = Math.max(1, Math.min(5, this.latticeSize));
console.log(`Loading point cloud with type: ${dataType}, endianness: ${isLittleEndian ? 'little' : 'big'}, quantization: ${clampedQuantization} bits, lattice size: ${this.latticeSize}`);
// Clear existing point clouds
this.instancedPointClouds.forEach(pc => this.scene.remove(pc));
this.instancedPointClouds = [];
const loadingMsg = document.getElementById('loadingMessage');
loadingMsg.style.display = 'block';
loadingMsg.innerHTML = `
<div>⏳ Processing data...</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">
Quantizing with ${clampedQuantization} bits...
</div>
`;
try {
const result = quantizeProcessDataAs(this.fileBuffer, dataType, isLittleEndian, clampedQuantization);
this.processedData = result; // Store for reference
const { points, colors, numPoints, quantizationRange } = result;
console.log(`Loaded ${numPoints} unique points from ${quantizationRange}^3 possible positions`);
if (numPoints === 0) {
loadingMsg.innerHTML = `
<div style="color: #ff6b6b;">❌ No points found</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">Try different data type or quantization settings</div>
`;
setTimeout(() => {
loadingMsg.style.display = 'none';
}, 3000);
return;
}
// Scale points and store both scaled points and colors for export
const scaleResult = this.scalePointsToUnitCube(points, colors, numPoints);
this.scaledPoints = scaleResult.scaledPoints;
this.scaledColors = scaleResult.scaledColors;
this.scaleInfo = scaleResult.scaleInfo;
this.createInstancedPointCloud(this.scaledPoints, this.scaledColors, numPoints);
const totalInstances = 8 * Math.pow(this.latticeSize, 3);
const totalPoints = numPoints * totalInstances;
document.getElementById('stats').innerHTML = `
<strong>Original Points:</strong> ${numPoints.toLocaleString()}<br>
<strong>Mirror Instances:</strong> 8 × ${this.latticeSize}³ = ${totalInstances}<br>
<strong>Total Display:</strong> ${totalPoints.toLocaleString()}<br>
<strong>Quantization:</strong> ${clampedQuantization} bits (${quantizationRange}³)<br>
<strong>Data Type:</strong> ${dataType.toUpperCase()}<br>
<strong>Scale:</strong> ${this.scaleInfo.scale.toFixed(4)}
`;
this.resetCamera();
this.updateSceneHelpers();
// Enable export
document.getElementById('exportPlyButton').disabled = false;
loadingMsg.style.display = 'none';
} catch (error) {
console.error('Error loading point cloud:', error);
loadingMsg.innerHTML = `
<div style="color: #ff6b6b;">❌ Processing failed</div>
<div style="font-size: 12px; margin-top: 8px; opacity: 0.8;">${error.message}</div>
`;
setTimeout(() => {
loadingMsg.style.display = 'none';
}, 5000);
}
}
createInstancedPointCloud(points, colors, numPoints) {
const geometry = new THREE.InstancedBufferGeometry();
const positions = new Float32Array(points.buffer, 0, numPoints * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const colorArray = new Float32Array(colors.buffer, 0, numPoints * 3);
geometry.setAttribute('color', new THREE.BufferAttribute(colorArray, 3));
const n = this.latticeSize;
const totalInstances = 8 * Math.pow(n, 3);
const instanceMirrors = new Float32Array(totalInstances * 3);
const instanceOffsets = new Float32Array(totalInstances * 3);
const mirrors = [
[1, 1, 1], [-1, 1, 1], [1, -1, 1], [1, 1, -1],
[-1, -1, 1], [-1, 1, -1], [1, -1, -1], [-1, -1, -1]
];
let instanceIndex = 0;
const offset = (n - 1) / 2;
for (let x = 0; x < n; x++) {
for (let y = 0; y < n; y++) {
for (let z = 0; z < n; z++) {
const posX = (x - offset) * 2;
const posY = (y - offset) * 2;
const posZ = (z - offset) * 2;
for (let m = 0; m < 8; m++) {
const baseIndex = instanceIndex * 3;
instanceMirrors[baseIndex] = mirrors[m][0];
instanceMirrors[baseIndex + 1] = mirrors[m][1];
instanceMirrors[baseIndex + 2] = mirrors[m][2];
instanceOffsets[baseIndex] = posX;
instanceOffsets[baseIndex + 1] = posY;
instanceOffsets[baseIndex + 2] = posZ;
instanceIndex++;
}
}
}
}
geometry.setAttribute('instanceMirror', new THREE.InstancedBufferAttribute(instanceMirrors, 3));
geometry.setAttribute('instanceOffset', new THREE.InstancedBufferAttribute(instanceOffsets, 3));
const pointSize = parseFloat(document.getElementById('pointSize').value) || 3.0;
const material = new InstancedMirrorPointMaterial();
material.uniforms.pointSize.value = pointSize;
const instancedPoints = new THREE.Points(geometry, material);
instancedPoints.frustumCulled = false;
this.scene.add(instancedPoints);
this.instancedPointClouds.push(instancedPoints);
console.log(`Created instanced point cloud with ${totalInstances} instances (${numPoints} points each)`);
}
updateSceneHelpers() {
this.scene.children = this.scene.children.filter(child =>
!child.isAxesHelper && !child.isGridHelper);
const axisSize = this.latticeSize * 1.5;
const axisHelper = new THREE.AxesHelper(axisSize);
this.scene.add(axisHelper);
const gridSize = this.latticeSize * 2;
const gridHelper = new THREE.GridHelper(gridSize, gridSize * 5);
gridHelper.position.y = -0.01;
this.scene.add(gridHelper);
}
scalePointsToUnitCube(points, colors, numPoints) {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
// Find bounds of the original points
for (let i = 0; i < numPoints * 3; i += 3) {
minX = Math.min(minX, points[i]);
minY = Math.min(minY, points[i + 1]);
minZ = Math.min(minZ, points[i + 2]);
maxX = Math.max(maxX, points[i]);
maxY = Math.max(maxY, points[i + 1]);
maxZ = Math.max(maxZ, points[i + 2]);
}
const sizeX = maxX - minX;
const sizeY = maxY - minY;
const sizeZ = maxZ - minZ;
const maxSize = Math.max(sizeX, sizeY, sizeZ);
const scale = maxSize > 0 ? 1 / maxSize : 1;
// Create scaled versions of both points and colors
const scaledPoints = new Float32Array(points.length);
const scaledColors = new Float32Array(colors.length);
for (let i = 0; i < numPoints * 3; i += 3) {
// Scale points to unit cube
scaledPoints[i] = (points[i] - minX) * scale;
scaledPoints[i + 1] = (points[i + 1] - minY) * scale;
scaledPoints[i + 2] = (points[i + 2] - minZ) * scale;
// Copy colors unchanged
scaledColors[i] = colors[i];
scaledColors[i + 1] = colors[i + 1];
scaledColors[i + 2] = colors[i + 2];
}
const scaleInfo = {
scale: scale,
minX: minX,
minY: minY,
minZ: minZ,
maxX: maxX,
maxY: maxY,
maxZ: maxZ,
sizeX: sizeX,
sizeY: sizeY,
sizeZ: sizeZ
};
console.log(`Scaled points to fit in unit cube. Scale: ${scale.toFixed(4)}, Bounds: [${minX.toFixed(3)}, ${maxX.toFixed(3)}] × [${minY.toFixed(3)}, ${maxY.toFixed(3)}] × [${minZ.toFixed(3)}, ${maxZ.toFixed(3)}]`);
return {
scaledPoints: scaledPoints,
scaledColors: scaledColors,
scaleInfo: scaleInfo
};
}
animate(currentTime = 0) {
requestAnimationFrame((time) => this.animate(time));
const deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
if (this.autoRotate) {
const rotationAmount = (deltaTime / 1000) * this.rotationSpeed;
const rotationQuaternion = new THREE.Quaternion();
rotationQuaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationAmount);
this.camera.position.applyQuaternion(rotationQuaternion);
this.camera.lookAt(0, 0, 0);
}
this.renderer.render(this.scene, this.camera);
}
}
// Initialize the application
document.addEventListener('DOMContentLoaded', () => {
new QuantizedMirrorPLYExporter();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment