LAZ (file format)
LAZ 파일 형식은 Lidar 포인트 클라우드 데이터를 저장하기 위해 특별히 설계된 LAS(Lidar LAser) 파일 형식의 압축 버전입니다. LAZ 파일은 LAS 파일과 동일한 데이터 및 구조를 유지하지만 무손실 압축 기술을 사용하여 원본 데이터 충실도를 유지하면서 파일 크기를 줄입니다.
ply-to-las.js
/**
* ply-to-las.js
*
* 브라우저에서 PLY(binary_little_endian) 파일을 LAS 1.4 바이너리로 변환.
* Converts a binary_little_endian PLY file into LAS 1.4 in the browser.
*
* 압축(LAZ)은 하지 않음 — laz-perf의 JS 바인딩은 디코더 전용이라
* 브라우저에서 실용적인 LAZ 인코딩이 불가능함. 압축/octree 빌드는
* 업로드 후 백엔드 PDAL(writers.copc)에서 처리하는 것을 전제로 함.
* No compression here (LAZ) — laz-perf's JS bindings are decode-only,
* so there's no practical LAZ encoder in the browser. Compression and
* octree building are expected to happen server-side via PDAL afterwards.
*
* 지원 property: x, y, z (필수), red/green/blue 또는 r/g/b (옵션, uchar 기준)
* Supported properties: x, y, z (required), red/green/blue or r/g/b (optional, uchar)
*
* 지원하지 않는 것 / Not supported:
* - ASCII PLY (binary_little_endian만 지원 / binary_little_endian only)
* - binary_big_endian
* - normal, custom scalar 필드는 파싱은 하되 LAS extra bytes로 옮기려면
* 별도 확장 필요 (아래 TODO 참고)
*/
// ---------------------------------------------------------------------------
// 1. PLY 헤더 파서 / PLY header parser
// ---------------------------------------------------------------------------
const PLY_TYPE_SIZES = {
char: 1, uchar: 1, int8: 1, uint8: 1,
short: 2, ushort: 2, int16: 2, uint16: 2,
int: 4, uint: 4, int32: 4, uint32: 4,
float: 4, float32: 4,
double: 8, float64: 8,
};
/**
* ArrayBuffer에서 PLY 헤더를 파싱하고, 헤더 끝의 바이트 오프셋을 함께 반환.
* Parses the PLY header from an ArrayBuffer and returns the byte offset
* where the header ends (i.e. where binary point data begins).
*/
function parsePlyHeader(buffer) {
// 헤더는 텍스트이므로 앞부분만 우선 디코딩해서 "end_header"를 찾는다.
// Header is ASCII text; decode a prefix chunk to locate "end_header".
const headerPreviewBytes = Math.min(buffer.byteLength, 1024 * 64); // 64KB면 충분 / 64KB is plenty
const previewText = new TextDecoder('ascii').decode(
new Uint8Array(buffer, 0, headerPreviewBytes)
);
const endHeaderIdx = previewText.indexOf('end_header');
if (endHeaderIdx === -1) {
throw new Error('PLY 헤더에서 end_header를 찾지 못했습니다 (헤더가 64KB를 초과하는지 확인).');
}
// "end_header\n" 다음 바이트부터 point data 시작
const newlineAfterEnd = previewText.indexOf('\n', endHeaderIdx);
const dataStartOffset = newlineAfterEnd + 1;
const headerText = previewText.slice(0, endHeaderIdx);
const lines = headerText.split('\n').map((l) => l.trim()).filter(Boolean);
if (lines[0] !== 'ply') {
throw new Error('유효한 PLY 파일이 아닙니다 (magic "ply" 없음).');
}
const formatLine = lines.find((l) => l.startsWith('format'));
if (!formatLine || !formatLine.includes('binary_little_endian')) {
throw new Error(
`지원하지 않는 PLY 포맷입니다: "${formatLine}". ` +
`이 변환기는 binary_little_endian만 지원합니다.`
);
}
// element/property 파싱 / Parse element & property declarations
const elements = [];
let currentElement = null;
for (const line of lines) {
if (line.startsWith('element')) {
const [, name, countStr] = line.split(/\s+/);
currentElement = { name, count: parseInt(countStr, 10), properties: [] };
elements.push(currentElement);
} else if (line.startsWith('property') && currentElement) {
const parts = line.split(/\s+/);
if (parts[1] === 'list') {
// list property는 vertex에는 거의 없고 face에만 쓰임 → 스킵 대상 표시
// list properties normally appear on "face", not "vertex" — mark for skip
currentElement.properties.push({
isList: true,
countType: parts[2],
valueType: parts[3],
name: parts[4],
});
} else {
currentElement.properties.push({
isList: false,
type: parts[1],
name: parts[2],
});
}
}
}
const vertexElement = elements.find((e) => e.name === 'vertex');
if (!vertexElement) {
throw new Error('PLY 파일에 vertex element가 없습니다.');
}
// vertex 레코드의 고정 바이트 길이 계산 (list property가 섞여 있으면 미지원)
// Compute fixed byte stride of a vertex record (list properties unsupported here)
let stride = 0;
for (const prop of vertexElement.properties) {
if (prop.isList) {
throw new Error('vertex element에 list property가 포함된 PLY는 지원하지 않습니다.');
}
const size = PLY_TYPE_SIZES[prop.type];
if (!size) throw new Error(`알 수 없는 PLY 타입: ${prop.type}`);
prop.offset = stride;
prop.size = size;
stride += size;
}
vertexElement.stride = stride;
return { elements, vertexElement, dataStartOffset };
}
// ---------------------------------------------------------------------------
// 2. vertex 데이터 추출 (x,y,z + 선택적 rgb) / Extract vertex data
// ---------------------------------------------------------------------------
function readVertexData(buffer, header) {
const { vertexElement, dataStartOffset } = header;
const { count, stride, properties } = vertexElement;
const propMap = Object.fromEntries(properties.map((p) => [p.name, p]));
const px = propMap.x, py = propMap.y, pz = propMap.z;
if (!px || !py || !pz) {
throw new Error('vertex element에 x, y, z property가 모두 있어야 합니다.');
}
// 색상 property 이름은 파일마다 red/green/blue 또는 r/g/b로 다름
// Color property names vary: red/green/blue vs r/g/b
const pr = propMap.red || propMap.r;
const pg = propMap.green || propMap.g;
const pb = propMap.blue || propMap.b;
const hasColor = Boolean(pr && pg && pb);
const view = new DataView(buffer, dataStartOffset, count * stride);
// Float64Array로 좌표 저장 (스케일/오프셋 계산 정밀도를 위해)
// Store coordinates as Float64Array for scale/offset precision
const xs = new Float64Array(count);
const ys = new Float64Array(count);
const zs = new Float64Array(count);
const colors = hasColor ? new Uint8Array(count * 3) : null;
const readTyped = (dv, offset, type) => {
switch (type) {
case 'float': case 'float32': return dv.getFloat32(offset, true);
case 'double': case 'float64': return dv.getFloat64(offset, true);
case 'uchar': case 'uint8': return dv.getUint8(offset);
case 'char': case 'int8': return dv.getInt8(offset);
case 'ushort': case 'uint16': return dv.getUint16(offset, true);
case 'short': case 'int16': return dv.getInt16(offset, true);
case 'uint': case 'uint32': return dv.getUint32(offset, true);
case 'int': case 'int32': return dv.getInt32(offset, true);
default: throw new Error(`읽기 미지원 타입: ${type}`);
}
};
for (let i = 0; i < count; i++) {
const base = i * stride;
xs[i] = readTyped(view, base + px.offset, px.type);
ys[i] = readTyped(view, base + py.offset, py.type);
zs[i] = readTyped(view, base + pz.offset, pz.type);
if (hasColor) {
colors[i * 3 + 0] = readTyped(view, base + pr.offset, pr.type);
colors[i * 3 + 1] = readTyped(view, base + pg.offset, pg.type);
colors[i * 3 + 2] = readTyped(view, base + pb.offset, pb.type);
}
}
return { count, xs, ys, zs, colors, hasColor };
}
// ---------------------------------------------------------------------------
// 3. LAS 1.4 바이너리 작성 / Write LAS 1.4 binary
// ---------------------------------------------------------------------------
const LAS_HEADER_SIZE = 375; // LAS 1.4 header size
const PDRF_WITH_COLOR = 2; // Point Data Record Format 2: xyz + intensity + ... + RGB
const PDRF_NO_COLOR = 0; // PDRF 0: xyz + intensity + ...
const RECORD_LEN_WITH_COLOR = 26;
const RECORD_LEN_NO_COLOR = 20;
/**
* 파싱된 vertex 데이터를 LAS 1.4 바이너리(Uint8Array)로 직렬화.
* Serializes parsed vertex data into a LAS 1.4 binary buffer.
*/
function writeLas(vertexData) {
const { count, xs, ys, zs, colors, hasColor } = vertexData;
// bounding box 계산 / Compute bounding box
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < count; i++) {
if (xs[i] < minX) minX = xs[i];
if (xs[i] > maxX) maxX = xs[i];
if (ys[i] < minY) minY = ys[i];
if (ys[i] > maxY) maxY = ys[i];
if (zs[i] < minZ) minZ = zs[i];
if (zs[i] > maxZ) maxZ = zs[i];
}
// 스케일: 0.001 (mm 단위 정밀도) — 산업용 스캔 데이터에 적합
// Scale: 0.001 (mm-level precision) — suitable for industrial scan data
const scale = 0.001;
const offsetX = minX, offsetY = minY, offsetZ = minZ;
const pdrf = hasColor ? PDRF_WITH_COLOR : PDRF_NO_COLOR;
const recordLen = hasColor ? RECORD_LEN_WITH_COLOR : RECORD_LEN_NO_COLOR;
const totalSize = LAS_HEADER_SIZE + count * recordLen;
const out = new ArrayBuffer(totalSize);
const dv = new DataView(out);
const u8 = new Uint8Array(out);
// --- 헤더 작성 / Write header ---
const writeStr = (offset, str, len) => {
for (let i = 0; i < len; i++) u8[offset + i] = i < str.length ? str.charCodeAt(i) : 0;
};
writeStr(0, 'LASF', 4); // File signature
dv.setUint16(4, 0, true); // File source ID
dv.setUint16(6, 0, true); // Global encoding
// Project ID (GUID) — 전부 0으로 채움 / left as zeros
dv.setUint8(26, 1); // Version major
dv.setUint8(27, 4); // Version minor
writeStr(28, 'CVP-PLY2LAS', 32); // System identifier
writeStr(60, 'ply-to-las.js', 32); // Generating software
dv.setUint16(92, 1, true); // File creation day of year
dv.setUint16(94, new Date().getFullYear(), true); // File creation year
dv.setUint16(96, LAS_HEADER_SIZE, true); // Header size
dv.setUint32(98, LAS_HEADER_SIZE, true); // Offset to point data
dv.setUint32(102, 0, true); // Number of VLRs
dv.setUint8(104, pdrf); // Point data record format
dv.setUint16(105, recordLen, true); // Point data record length
dv.setUint32(107, 0, true); // Legacy point count (1.4는 0 허용, 아래 확장 필드 사용)
// Legacy point count by return (5 x uint32) — 0으로 둠
dv.setFloat64(131, scale, true); // X scale
dv.setFloat64(139, scale, true); // Y scale
dv.setFloat64(147, scale, true); // Z scale
dv.setFloat64(155, offsetX, true); // X offset
dv.setFloat64(163, offsetY, true); // Y offset
dv.setFloat64(171, offsetZ, true); // Z offset
dv.setFloat64(179, maxX, true); // Max X
dv.setFloat64(187, minX, true); // Min X
dv.setFloat64(195, maxY, true); // Max Y
dv.setFloat64(203, minY, true); // Min Y
dv.setFloat64(211, maxZ, true); // Max Z
dv.setFloat64(219, minZ, true); // Min Z
// Waveform data packet offset (227, 8bytes) = 0
// Start of first EVLR (235, 8bytes) = 0
// Number of EVLRs (243, 4bytes) = 0
dv.setBigUint64(247, BigInt(count), true); // Number of point records (LAS 1.4 extended)
// Number of points by return (15 x uint64, 255~) — 0으로 둠
// --- point records 작성 / Write point records ---
let ptr = LAS_HEADER_SIZE;
for (let i = 0; i < count; i++) {
const ix = Math.round((xs[i] - offsetX) / scale);
const iy = Math.round((ys[i] - offsetY) / scale);
const iz = Math.round((zs[i] - offsetZ) / scale);
dv.setInt32(ptr + 0, ix, true);
dv.setInt32(ptr + 4, iy, true);
dv.setInt32(ptr + 8, iz, true);
dv.setUint16(ptr + 12, 0, true); // Intensity
dv.setUint8(ptr + 14, 1); // Return number(bits) / classification flags 등, 기본값
dv.setUint8(ptr + 15, 0); // Classification
dv.setInt8(ptr + 16, 0); // Scan angle
dv.setUint8(ptr + 17, 0); // User data
dv.setUint16(ptr + 18, 0, true); // Point source ID
if (hasColor) {
// LAS는 16bit 컬러 채널 사용 → 8bit 값을 << 8로 확장
// LAS uses 16-bit color channels → scale up from 8-bit PLY values
dv.setUint16(ptr + 20, colors[i * 3 + 0] << 8, true);
dv.setUint16(ptr + 22, colors[i * 3 + 1] << 8, true);
dv.setUint16(ptr + 24, colors[i * 3 + 2] << 8, true);
}
ptr += recordLen;
}
return u8;
}
// ---------------------------------------------------------------------------
// 4. 공개 API / Public API
// ---------------------------------------------------------------------------
/**
* File 객체(PLY)를 받아 LAS 바이너리(Blob)로 변환.
* Converts a File (PLY) into a LAS binary Blob.
*
* @param {File} plyFile
* @returns {Promise<Blob>} LAS 1.4 바이너리를 담은 Blob
*/
export async function convertPlyFileToLas(plyFile) {
// 몇백 MB 파일도 arrayBuffer()로 한번에 읽는 게 일반 데스크톱 브라우저에서는 무리 없음.
// 다만 모바일/저사양 환경 대응이 필요하면 File.slice()로 청크 단위 스트리밍 파싱으로 바꿔야 함.
// Reading via arrayBuffer() at once is fine on desktop for a few hundred MB.
// For mobile/low-memory targets, switch to chunked parsing via File.slice().
const buffer = await plyFile.arrayBuffer();
const header = parsePlyHeader(buffer);
const vertexData = readVertexData(buffer, header);
const lasBytes = writeLas(vertexData);
return new Blob([lasBytes], { type: 'application/octet-stream' });
}
/**
* 사용 예시 / Usage example:
*
* const input = document.querySelector('#ply-input');
* input.addEventListener('change', async (e) => {
* const file = e.target.files[0];
* const lasBlob = await convertPlyFileToLas(file);
* // lasBlob을 R2 presigned URL로 업로드
* await fetch(presignedUrl, { method: 'PUT', body: lasBlob });
* });
*
* TODO: 몇백 MB급 파일에서 메인 스레드 블로킹을 피하려면
* 이 모듈을 Web Worker 안으로 옮기고 postMessage로 결과 Blob을 전달할 것.
* To avoid blocking the main thread on large files, move this module
* into a Web Worker and transfer the resulting Blob via postMessage.
*/