export const PLY_IMPORT_SCHEMA_VERSION = 1 as const; export const PLY_IMPORT_BUDGET = { maxBytes: 512 * 1024, maxHeaderBytes: 64 * 1024, maxElements: 16, maxVertices: 65_536, maxFaces: 65_536, maxListLength: 256, maxCustomProperties: 64, } as const; export type PLYFormat = "ascii" | "binary_little_endian"; export interface PLYVertex { position: [number, number, number]; normal: [number, number, number] | null; color: [number, number, number, number] | null; customProperties: Record; } export interface PLYFace { indices: number[]; customProperties: Record; } export type PLYLossCode = | "PLY_UNKNOWN_ELEMENT" | "PLY_UNKNOWN_PROPERTY" | "PLY_NORMAL_PROPERTY_INCOMPLETE" | "PLY_COLOR_PROPERTY_INCOMPLETE"; export interface PLYLossWarning { code: PLYLossCode; severity: "warning"; element: string; property: string | null; message: string; } export interface PLYLossReport { schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION; operation: "PLY_IMPORT_LOSS_REPORT"; canImport: boolean; warningCount: number; warnings: PLYLossWarning[]; } export interface PLYImportResult { schemaVersion: typeof PLY_IMPORT_SCHEMA_VERSION; format: PLYFormat; vertices: PLYVertex[]; faces: PLYFace[]; warnings: PLYLossWarning[]; } type ScalarType = "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" | "float32" | "float64"; interface ScalarProperty { kind: "scalar"; name: string; type: ScalarType; } interface ListProperty { kind: "list"; name: string; countType: ScalarType; valueType: ScalarType; } type Property = ScalarProperty | ListProperty; interface Element { name: string; count: number; properties: Property[]; } const SCALAR_TYPES: Record = { char: "int8", int8: "int8", uchar: "uint8", uint8: "uint8", short: "int16", int16: "int16", ushort: "uint16", uint16: "uint16", int: "int32", int32: "int32", uint: "uint32", uint32: "uint32", float: "float32", float32: "float32", double: "float64", float64: "float64", }; function fail(code: string): never { throw new Error(code); } function finite(value: number, label: string): number { if (!Number.isFinite(value)) fail(`PLY_NUMBER_INVALID: ${label}`); return Object.is(value, -0) ? 0 : value; } function decodeHeader(bytes: Uint8Array): { format: PLYFormat; elements: Element[]; offset: number } { const limit = Math.min(bytes.byteLength, PLY_IMPORT_BUDGET.maxHeaderBytes); let end = -1; let terminatorLength = 0; for (let index = 0; index + 10 <= limit; index++) { if (bytes[index] === 101 && bytes[index + 1] === 110 && bytes[index + 2] === 100 && bytes[index + 3] === 95 && bytes[index + 4] === 104 && bytes[index + 5] === 101 && bytes[index + 6] === 97 && bytes[index + 7] === 100 && bytes[index + 8] === 101 && bytes[index + 9] === 114) { if (bytes[index + 10] === 10) { end = index; terminatorLength = 11; break; } if (bytes[index + 10] === 13 && bytes[index + 11] === 10) { end = index; terminatorLength = 12; break; } } } if (end < 0) fail("PLY_HEADER_INVALID"); let header: string; try { header = new TextDecoder("ascii", { fatal: true }).decode(bytes.subarray(0, end)); } catch { fail("PLY_HEADER_INVALID"); } const lines = header.split(/\r?\n/); if (lines[0] !== "ply") fail("PLY_MAGIC_INVALID"); let format: PLYFormat | null = null; const elements: Element[] = []; let current: Element | null = null; for (const rawLine of lines.slice(1)) { const line = rawLine.trim(); if (!line || line.startsWith("comment") || line.startsWith("obj_info")) continue; const parts = line.split(/\s+/); if (parts[0] === "format") { if (parts[1] === "ascii") format = "ascii"; else if (parts[1] === "binary_little_endian") format = "binary_little_endian"; else fail("PLY_FORMAT_UNSUPPORTED"); } else if (parts[0] === "element") { if (parts.length !== 3 || !Number.isSafeInteger(Number(parts[2])) || Number(parts[2]) < 0) fail("PLY_ELEMENT_INVALID"); if (elements.length >= PLY_IMPORT_BUDGET.maxElements) fail("PLY_IMPORT_BUDGET_EXCEEDED: elements"); const count = Number(parts[2]); if (count > PLY_IMPORT_BUDGET.maxVertices) fail(`PLY_IMPORT_BUDGET_EXCEEDED: ${parts[1]}`); current = { name: parts[1], count, properties: [] }; elements.push(current); } else if (parts[0] === "property") { if (!current) fail("PLY_PROPERTY_WITHOUT_ELEMENT"); if (parts[1] === "list") { if (parts.length !== 5) fail("PLY_PROPERTY_INVALID"); const countType = SCALAR_TYPES[parts[2]]; const valueType = SCALAR_TYPES[parts[3]]; if (!countType || !valueType) fail("PLY_PROPERTY_TYPE_UNSUPPORTED"); current.properties.push({ kind: "list", name: parts[4], countType, valueType }); } else { if (parts.length !== 3) fail("PLY_PROPERTY_INVALID"); const type = SCALAR_TYPES[parts[1]]; if (!type) fail("PLY_PROPERTY_TYPE_UNSUPPORTED"); current.properties.push({ kind: "scalar", name: parts[2], type }); } } else if (parts[0] !== "end_header") fail("PLY_HEADER_INVALID"); } if (!format) fail("PLY_FORMAT_MISSING"); return { format, elements, offset: end + terminatorLength }; } function readScalar(view: DataView, offset: number, type: ScalarType): { value: number; next: number } { const size = type === "int8" || type === "uint8" ? 1 : type === "int16" || type === "uint16" ? 2 : 4; if (offset + size > view.byteLength) fail("PLY_DATA_TRUNCATED"); let value: number; if (type === "int8") value = view.getInt8(offset); else if (type === "uint8") value = view.getUint8(offset); else if (type === "int16") value = view.getInt16(offset, true); else if (type === "uint16") value = view.getUint16(offset, true); else if (type === "int32") value = view.getInt32(offset, true); else if (type === "uint32") value = view.getUint32(offset, true); else if (type === "float32") value = view.getFloat32(offset, true); else value = view.getFloat64(offset, true); return { value: finite(value, "binary"), next: offset + size }; } function parseAsciiRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map>> { let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(offset)); } catch { fail("PLY_ASCII_INVALID"); } const lines = text.split(/\r?\n/); let cursor = 0; const records = new Map>>(); for (const element of elements) { const values: Array> = []; for (let row = 0; row < element.count; row++) { while (cursor < lines.length && !lines[cursor].trim()) cursor++; if (cursor >= lines.length) fail("PLY_DATA_TRUNCATED"); const tokens = lines[cursor++].trim().split(/\s+/); let tokenIndex = 0; const record: Record = {}; for (const property of element.properties) { if (property.kind === "scalar") { if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED"); record[property.name] = finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`); } else { if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED"); const length = Number(tokens[tokenIndex++]); if (!Number.isSafeInteger(length) || length < 0 || length > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID"); const list: number[] = []; for (let index = 0; index < length; index++) { if (tokenIndex >= tokens.length) fail("PLY_DATA_TRUNCATED"); list.push(finite(Number(tokens[tokenIndex++]), `${element.name}.${property.name}`)); } record[property.name] = list; } } if (tokenIndex !== tokens.length) fail("PLY_DATA_EXTRA_TOKENS"); values.push(record); } records.set(element.name, values); } return records; } function parseBinaryRecords(bytes: Uint8Array, offset: number, elements: Element[]): Map>> { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let cursor = offset; const records = new Map>>(); for (const element of elements) { const values: Array> = []; for (let row = 0; row < element.count; row++) { const record: Record = {}; for (const property of element.properties) { if (property.kind === "scalar") { const result = readScalar(view, cursor, property.type); record[property.name] = result.value; cursor = result.next; } else { const count = readScalar(view, cursor, property.countType); cursor = count.next; if (!Number.isSafeInteger(count.value) || count.value < 0 || count.value > PLY_IMPORT_BUDGET.maxListLength) fail("PLY_LIST_INVALID"); const list: number[] = []; for (let index = 0; index < count.value; index++) { const result = readScalar(view, cursor, property.valueType); list.push(result.value); cursor = result.next; } record[property.name] = list; } } values.push(record); } records.set(element.name, values); } return records; } function warning(code: PLYLossCode, element: string, property: string | null, message: string): PLYLossWarning { return { code, severity: "warning", element, property, message }; } function mapDocument(elements: Element[], records: Map>>): PLYImportResult { const warnings: PLYLossWarning[] = []; const vertexElement = elements.find((element) => element.name === "vertex"); if (!vertexElement) fail("PLY_VERTEX_ELEMENT_MISSING"); const vertexRecords = records.get("vertex") ?? []; const vertexProperties = new Set(vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name)); for (const name of ["x", "y", "z"]) if (!vertexProperties.has(name)) fail("PLY_VERTEX_POSITION_MISSING"); const hasNormals = ["nx", "ny", "nz"].every((name) => vertexProperties.has(name)); if (!hasNormals && ["nx", "ny", "nz"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_NORMAL_PROPERTY_INCOMPLETE", "vertex", null, "vertex normal requires nx, ny and nz")); const hasColor = ["red", "green", "blue"].every((name) => vertexProperties.has(name)); if (!hasColor && ["red", "green", "blue", "alpha"].some((name) => vertexProperties.has(name))) warnings.push(warning("PLY_COLOR_PROPERTY_INCOMPLETE", "vertex", null, "vertex color requires red, green and blue")); const customNames = vertexElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar" && !["x", "y", "z", "nx", "ny", "nz", "red", "green", "blue", "alpha"].includes(property.name)).map((property) => property.name); if (customNames.length > PLY_IMPORT_BUDGET.maxCustomProperties) fail("PLY_IMPORT_BUDGET_EXCEEDED: custom properties"); for (const property of vertexElement.properties) if (property.kind === "list") warnings.push(warning("PLY_UNKNOWN_PROPERTY", "vertex", property.name, `vertex list property ${property.name} is not mapped`)); const vertices = vertexRecords.map((record) => ({ position: [record.x, record.y, record.z].map((value) => finite(value as number, "vertex position")) as [number, number, number], normal: hasNormals ? [record.nx, record.ny, record.nz].map((value) => finite(value as number, "vertex normal")) as [number, number, number] : null, color: hasColor ? ["red", "green", "blue", "alpha"].map((name) => Math.max(0, Math.min(255, Number(record[name] ?? (name === "alpha" ? 255 : 0)))) / 255) as [number, number, number, number] : null, customProperties: Object.fromEntries(customNames.map((name) => [name, finite(record[name] as number, `vertex.${name}`)])), })); const faceElement = elements.find((element) => element.name === "face"); const faces: PLYFace[] = []; if (faceElement) { const indexProperty = faceElement.properties.find((property): property is ListProperty => property.kind === "list" && (property.name === "vertex_indices" || property.name === "vertex_index")); if (!indexProperty) fail("PLY_FACE_INDEX_MISSING"); const faceCustomNames = faceElement.properties.filter((property): property is ScalarProperty => property.kind === "scalar").map((property) => property.name); for (const property of faceElement.properties) if (property.kind === "list" && property !== indexProperty) warnings.push(warning("PLY_UNKNOWN_PROPERTY", "face", property.name, `face list property ${property.name} is not mapped`)); for (const record of records.get("face") ?? []) { const values = record[indexProperty.name]; if (!Array.isArray(values) || values.length < 3) fail("PLY_FACE_ARITY_INVALID"); const indices = values.map((value) => { if (!Number.isSafeInteger(value) || value < 0 || value >= vertices.length) fail("PLY_FACE_INDEX_OUT_OF_RANGE"); return value; }); faces.push({ indices, customProperties: Object.fromEntries(faceCustomNames.map((name) => [name, finite(record[name] as number, `face.${name}`)])) }); } } for (const element of elements) if (element.name !== "vertex" && element.name !== "face") warnings.push(warning("PLY_UNKNOWN_ELEMENT", element.name, null, `element ${element.name} is not mapped`)); return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, format: "ascii", vertices, faces, warnings }; } export function importPLY(bytes: ArrayBuffer, options?: { format?: PLYFormat }): PLYImportResult { if (bytes.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: bytes"); const payload = new Uint8Array(bytes); const header = decodeHeader(payload); if (options?.format && options.format !== header.format) fail("PLY_FORMAT_MISMATCH"); const records = header.format === "ascii" ? parseAsciiRecords(payload, header.offset, header.elements) : parseBinaryRecords(payload, header.offset, header.elements); const result = mapDocument(header.elements, records); result.format = header.format; return result; } export function createPLYLossReport(document: PLYImportResult): PLYLossReport { const warnings = [...document.warnings].sort((left, right) => left.code.localeCompare(right.code) || left.element.localeCompare(right.element) || (left.property ?? "").localeCompare(right.property ?? "")); return { schemaVersion: PLY_IMPORT_SCHEMA_VERSION, operation: "PLY_IMPORT_LOSS_REPORT", canImport: true, warningCount: warnings.length, warnings }; } function formatNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(9))); } export function serializePLYAscii(document: PLYImportResult): ArrayBuffer { if (document.schemaVersion !== PLY_IMPORT_SCHEMA_VERSION || document.vertices.length > PLY_IMPORT_BUDGET.maxVertices || document.faces.length > PLY_IMPORT_BUDGET.maxFaces) fail("PLY_EXPORT_DOCUMENT_INVALID"); const customNames = [...new Set(document.vertices.flatMap((vertex) => Object.keys(vertex.customProperties)))].sort(); const faceCustomNames = [...new Set(document.faces.flatMap((face) => Object.keys(face.customProperties)))].sort(); const lines = ["ply", "format ascii 1.0", "comment Web Blender PLY schema 1", `element vertex ${document.vertices.length}`, "property float x", "property float y", "property float z"]; if (document.vertices.some((vertex) => vertex.normal)) lines.push("property float nx", "property float ny", "property float nz"); if (document.vertices.some((vertex) => vertex.color)) lines.push("property uchar red", "property uchar green", "property uchar blue", "property uchar alpha"); for (const name of customNames) lines.push(`property float ${name}`); lines.push(`element face ${document.faces.length}`, "property list uchar uint vertex_indices"); for (const name of faceCustomNames) lines.push(`property float ${name}`); lines.push("end_header"); for (const vertex of document.vertices) { const values = vertex.position.map(formatNumber); if (document.vertices.some((item) => item.normal)) values.push(...(vertex.normal ?? [0, 0, 0]).map(formatNumber)); if (document.vertices.some((item) => item.color)) values.push(...(vertex.color ?? [0, 0, 0, 1]).map((value) => String(Math.max(0, Math.min(255, Math.round(value * 255)))))); values.push(...customNames.map((name) => formatNumber(vertex.customProperties[name] ?? 0))); lines.push(values.join(" ")); } for (const face of document.faces) lines.push(`${face.indices.length} ${face.indices.join(" ")} ${faceCustomNames.map((name) => formatNumber(face.customProperties[name] ?? 0)).join(" ")}`.trim()); const output = new TextEncoder().encode(lines.join("\n") + "\n"); if (output.byteLength > PLY_IMPORT_BUDGET.maxBytes) fail("PLY_IMPORT_BUDGET_EXCEEDED: output bytes"); return output.buffer; }