diff --git a/app/workers/converter.worker.ts b/app/workers/converter.worker.ts index 46beb5d..0a67111 100644 --- a/app/workers/converter.worker.ts +++ b/app/workers/converter.worker.ts @@ -1,19 +1,14 @@ /* eslint-disable no-restricted-globals */ +import JSZip from "jszip"; + +// --- BINARY PARSERS --- -/** - * Converts XGrids .lci binary to PLY Mesh - */ function parseLci(buffer: ArrayBuffer): Blob { const view = new DataView(buffer); - const LCI_MAGIC = 0x6c6c6f63; // 'coll' - - if (view.getUint32(0, true) !== LCI_MAGIC) { - throw new Error("Invalid LCI Magic Number"); - } - + const LCI_MAGIC = 0x6c6c6f63; + if (view.getUint32(0, true) !== LCI_MAGIC) throw new Error("Invalid LCI"); const meshNum = view.getUint32(44, true); const meshes = []; - for (let i = 0; i < meshNum; i++) { const offset = 48 + i * 40; meshes.push({ @@ -22,40 +17,28 @@ function parseLci(buffer: ArrayBuffer): Blob { faceNum: view.getUint32(offset + 28, true), }); } - - let verticesStr = ""; - let facesStr = ""; - let vOffset = 0; - + let vStr = ""; + let fStr = ""; + let vOff = 0; for (const m of meshes) { let pos = m.dataOffset; for (let j = 0; j < m.vertexNum; j++) { - verticesStr += `${view.getFloat32(pos, true).toFixed(6)} ${view.getFloat32(pos + 4, true).toFixed(6)} ${view.getFloat32(pos + 8, true).toFixed(6)}\n`; + vStr += `${view.getFloat32(pos, true).toFixed(6)} ${view.getFloat32(pos + 4, true).toFixed(6)} ${view.getFloat32(pos + 8, true).toFixed(6)}\n`; pos += 12; } for (let j = 0; j < m.faceNum; j++) { - facesStr += `3 ${view.getUint32(pos, true) + vOffset} ${view.getUint32(pos + 4, true) + vOffset} ${view.getUint32(pos + 8, true) + vOffset}\n`; + fStr += `3 ${view.getUint32(pos, true) + vOff} ${view.getUint32(pos + 4, true) + vOff} ${view.getUint32(pos + 8, true) + vOff}\n`; pos += 12; } - vOffset += m.vertexNum; + vOff += m.vertexNum; } - - const header = `ply\nformat ascii 1.0\nelement vertex ${vOffset}\nproperty float x\nproperty float y\nproperty float z\nelement face ${meshes.reduce((a, b) => a + b.faceNum, 0)}\nproperty list uchar int vertex_indices\nend_header\n`; - return new Blob([header + verticesStr + facesStr], { - type: "application/octet-stream", - }); + const header = `ply\nformat ascii 1.0\nelement vertex ${vOff}\nproperty float x\nproperty float y\nproperty float z\nelement face ${meshes.reduce((a, b) => a + b.faceNum, 0)}\nproperty list uchar int vertex_indices\nend_header\n`; + return new Blob([header + vStr + fStr], { type: "application/octet-stream" }); } -/** - * Converts XGrids environment.bin to PlayCanvas Gaussian Splat PLY - */ -/** - * Converts XGrids environment.bin to a Full Gaussian Splatting PLY - * Extracts: Position, Scale, Rotation (Quaternions), and Opacity - */ function parseEnvironment(buffer: ArrayBuffer): Blob { const view = new DataView(buffer); - const POINT_SIZE = 44; // 11 floats * 4 bytes + const POINT_SIZE = 44; const numPoints = Math.floor(buffer.byteLength / POINT_SIZE); let plyHeader = [ @@ -65,6 +48,9 @@ function parseEnvironment(buffer: ArrayBuffer): Blob { "property float x", "property float y", "property float z", + "property float f_dc_0", // Red + "property float f_dc_1", // Green + "property float f_dc_2", // Blue "property float scale_0", "property float scale_1", "property float scale_2", @@ -77,29 +63,34 @@ function parseEnvironment(buffer: ArrayBuffer): Blob { "", ].join("\n"); - // 2. Extract all 11 properties for every point - // We use an array of strings to build the body efficiently const rows: string[] = []; for (let i = 0; i < numPoints; i++) { const offset = i * POINT_SIZE; - const pointData = []; - for (let j = 0; j < 11; j++) { - // getFloat32(offset, littleEndian: true) - const val = view.getFloat32(offset + j * 4, true); - pointData.push(val.toFixed(6)); - } + // X, Y, Z (first 3 floats) + const x = view.getFloat32(offset, true).toFixed(6); + const y = view.getFloat32(offset + 4, true).toFixed(6); + const z = view.getFloat32(offset + 8, true).toFixed(6); + const f_dc = "1.000000 1.000000 1.000000"; - rows.push(pointData.join(" ")); + // Scales (floats 4, 5, 6) + const s0 = view.getFloat32(offset + 12, true).toFixed(6); + const s1 = view.getFloat32(offset + 16, true).toFixed(6); + const s2 = view.getFloat32(offset + 20, true).toFixed(6); - // Periodically clear memory pressure if the scene is massive - if (i % 100000 === 0 && i > 0) { - self.postMessage({ - type: "LOG", - message: `Processing Environment: ${i.toLocaleString()} splats...`, - }); - } + // Rotations (floats 7, 8, 9, 10) + const r0 = view.getFloat32(offset + 24, true).toFixed(6); + const r1 = view.getFloat32(offset + 28, true).toFixed(6); + const r2 = view.getFloat32(offset + 32, true).toFixed(6); + const r3 = view.getFloat32(offset + 36, true).toFixed(6); + + // Opacity (float 11) + const alpha = view.getFloat32(offset + 40, true).toFixed(6); + + rows.push( + `${x} ${y} ${z} ${f_dc} ${s0} ${s1} ${s2} ${r0} ${r1} ${r2} ${r3} ${alpha}`, + ); } return new Blob([plyHeader + rows.join("\n")], { @@ -122,118 +113,146 @@ self.onmessage = async (e: MessageEvent) => { if (type === "START_CONVERSION") { try { - const generatedFiles: { name: string; blob: Blob }[] = []; + const zip = new JSZip(); + self.postMessage({ type: "LOG", message: "Initialisiere Pipeline..." }); - // 1. Process LCI (Collision) - const lciData = filesData.find((f: any) => f.name === "collision.lci"); - if (lciData) { - self.postMessage({ type: "LOG", message: "Parsing Collision Mesh..." }); - generatedFiles.push({ - name: "collision_mesh.ply", - blob: parseLci(lciData.buffer), - }); - } + // 1. Parse and add collision + environment meshes to ZIP + const lciFile = filesData.find((f: any) => f.name === "collision.lci"); + if (lciFile) zip.file("collision.ply", parseLci(lciFile.buffer)); - // 2. Process Environment (Point Cloud) - const envData = filesData.find((f: any) => f.name === "environment.bin"); - if (envData) { - self.postMessage({ - type: "LOG", - message: "Parsing Environment Cloud...", - }); - generatedFiles.push({ - name: "environment_reference.ply", - blob: parseEnvironment(envData.buffer), - }); - } - - // 3. Process Splat Transformation (SOG / LODs) - self.postMessage({ - type: "LOG", - message: "Initializing PlayCanvas Splat Transform...", - }); + const envFile = filesData.find((f: any) => f.name === "environment.bin"); + if (envFile) + zip.file("environment.ply", parseEnvironment(envFile.buffer)); + // 2. Import splat-transform API const { readFile, writeFile, + processDataTable, + combine, MemoryReadFileSystem, MemoryFileSystem, getInputFormat, + getOutputFormat, } = await import("@playcanvas/splat-transform"); + // 3. Build in-memory read filesystem from all uploaded files const readFs = new MemoryReadFileSystem(); - for (const file of filesData) { - readFs.set(file.name, new Uint8Array(file.buffer)); - } + for (const f of filesData) readFs.set(f.name, new Uint8Array(f.buffer)); - const commonOptions = { - iterations: 10, - lodSelect: [0, 1, 2, 3, 4], + // 4. Read each LOD level separately from the LCC + const lccFile = filesData.find((f: any) => f.name === mainLccName); + if (!lccFile) throw new Error("LCC-Datei nicht gefunden."); + + const lccText = new TextDecoder().decode(lccFile.buffer); + const lccJson = JSON.parse(lccText); + const totalLevels: number = lccJson?.totalLevel ?? 5; + + self.postMessage({ + type: "LOG", + message: `LCC enthält ${totalLevels} LOD-Level.`, + }); + + // 5. Read each LOD level separately from the LCC + const lodOptions = { + iterations: 0, unbundled: false, lodChunkCount: 0, lodChunkExtent: 0, }; - const tables = await readFile({ - filename: mainLccName, - fileSystem: readFs, - inputFormat: getInputFormat(mainLccName), - params: [], - options: { ...commonOptions, iterations: 0 }, + const taggedTables = []; + for (let i = 0; i < totalLevels; i++) { + self.postMessage({ type: "LOG", message: `Lese LOD ${i}...` }); + const tables = await readFile({ + filename: mainLccName, + fileSystem: readFs, + inputFormat: getInputFormat(mainLccName), + params: [], + options: { + ...lodOptions, + lodSelect: [i], + }, + }); + + if (!tables || tables.length === 0) { + self.postMessage({ + type: "LOG", + message: `LOD ${i} nicht gefunden, überspringe...`, + }); + continue; + } + + const tagged = processDataTable(tables[0], [{ kind: "lod", value: i }]); + taggedTables.push(tagged); + } + if (taggedTables.length === 0) + throw new Error("Keine LOD-Daten gefunden."); + + self.postMessage({ + type: "LOG", + message: `${taggedTables.length} LOD-Level geladen. Kombiniere...`, }); - const mainTable = tables[0]; - if (!mainTable) throw new Error("No Splat data found."); + // 5. Combine all tagged LOD tables + const combined = combine(taggedTables); - // PASS: Single SOG - self.postMessage({ type: "LOG", message: "Compiling High-Res SOG..." }); - const writeFsSingle = new MemoryFileSystem(); - await writeFile( - { - filename: `${fileName}.sog`, - outputFormat: "sog-bundle", - dataTable: mainTable, - options: commonOptions, - }, - writeFsSingle, - ); + // 7. Write LOD streaming format + // CRITICAL: output filename MUST be exactly "lod-meta.json" + // The format is determined by the filename, not a format string. + self.postMessage({ + type: "LOG", + message: "Kompiliere LOD Chunks (World Mode)...", + }); - const singleData = writeFsSingle.results.get(`${fileName}.sog`); - if (singleData) { - generatedFiles.push({ - name: `${fileName}.sog`, - blob: new Blob([new Uint8Array(singleData).buffer]), - }); - } - - // PASS: LOD Chunks - self.postMessage({ type: "LOG", message: "Compiling LOD Chunks..." }); const writeFsLods = new MemoryFileSystem(); + const lodSelectAll = Array.from({ length: totalLevels }, (_, i) => i); + const outputFormat = getOutputFormat("lod-meta.json", { + iterations: 10, + lodSelect: lodSelectAll, + unbundled: true, + lodChunkCount: 512, // default value from CLI tool + lodChunkExtent: 16, // default value from CLI tool + }); + await writeFile( { - filename: "meta.json", - outputFormat: "sog", - dataTable: mainTable, + filename: "lod-meta.json", + outputFormat, + dataTable: combined, options: { - ...commonOptions, + iterations: 10, + lodSelect: lodSelectAll, unbundled: true, - lodChunkCount: 512, - lodChunkExtent: 16, + lodChunkCount: 512, // default value from CLI tool + lodChunkExtent: 16, // default value from CLI tool }, }, writeFsLods, ); + // 8. Organize output files into ZIP folder structure + // The API emits files named like: "0_0_meta.json", "0_0_means_l.webp", "env_meta.json" etc. + // We reorganize these into: 0_0/meta.json, 0_0/means_l.webp, env/meta.json + self.postMessage({ + type: "LOG", + message: "Organisiere Ordnerstruktur...", + }); + for (const [name, data] of writeFsLods.results.entries()) { - generatedFiles.push({ - name, - blob: new Blob([new Uint8Array(data).buffer]), - }); + zip.file(name, data); } - self.postMessage({ type: "DONE", data: { files: generatedFiles } }); + // 9. Generate and emit ZIP + self.postMessage({ type: "LOG", message: "Erstelle ZIP-Datei..." }); + const zipBlob = await zip.generateAsync({ type: "blob" }); + + self.postMessage({ + type: "DONE", + data: { files: [{ name: `${fileName}_pack.zip`, blob: zipBlob }] }, + }); } catch (err: any) { - self.postMessage({ type: "LOG", message: `Error: ${err.message}` }); + self.postMessage({ type: "LOG", message: `Fehler: ${err.message}` }); } } }; diff --git a/package-lock.json b/package-lock.json index dbc2855..c924dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@playcanvas/splat-transform": "^1.8.0", + "jszip": "^3.10.1", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" @@ -3068,6 +3069,12 @@ "webpack": "^5.1.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4492,6 +4499,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4523,9 +4536,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "optional": true, - "peer": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -5128,6 +5139,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5172,6 +5231,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -6029,6 +6097,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6183,6 +6257,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6649,6 +6729,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -7563,9 +7649,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/watchpack": { "version": "2.5.1", diff --git a/package.json b/package.json index 98a9ae3..ae53efa 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@playcanvas/splat-transform": "^1.8.0", + "jszip": "^3.10.1", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3"