Last active
August 14, 2025 19:53
-
-
Save lardratboy/01e2b1d9e3ab2af86ce230034841cc72 to your computer and use it in GitHub Desktop.
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>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