LAZ (file format)
lastmod 2026-07-19created 2026-07-191 edits
The LAZ file format is a compressed version of the LAS (Lidar LAser) file format, designed specifically for storing Lidar point cloud data. LAZ files keep the same data and structure as LAS files, but use lossless compression to reduce file size while preserving the fidelity of the original data.
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.
*/