299 lines
17 KiB
TypeScript
299 lines
17 KiB
TypeScript
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<string, number>;
|
|
}
|
|
|
|
export interface PLYFace {
|
|
indices: number[];
|
|
customProperties: Record<string, number>;
|
|
}
|
|
|
|
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<string, ScalarType> = {
|
|
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<string, Array<Record<string, number | number[]>>> {
|
|
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<string, Array<Record<string, number | number[]>>>();
|
|
for (const element of elements) {
|
|
const values: Array<Record<string, number | number[]>> = [];
|
|
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<string, number | number[]> = {};
|
|
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<string, Array<Record<string, number | number[]>>> {
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
let cursor = offset;
|
|
const records = new Map<string, Array<Record<string, number | number[]>>>();
|
|
for (const element of elements) {
|
|
const values: Array<Record<string, number | number[]>> = [];
|
|
for (let row = 0; row < element.count; row++) {
|
|
const record: Record<string, number | number[]> = {};
|
|
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<string, Array<Record<string, number | number[]>>>): 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;
|
|
}
|