99const { app : electronApp , BrowserWindow, ipcMain, dialog, shell, Menu, protocol, net } = require ( 'electron' ) ;
1010const path = require ( 'path' ) ;
1111const crypto = require ( 'crypto' ) ;
12+ const { extractGzipProjectEntry } = require ( './project-extractor.cjs' ) ;
1213
1314// ---------------------------------------------------------------------------
1415// Raise renderer V8 heap limit above the ~4 GB default
@@ -27,7 +28,12 @@ const crypto = require('crypto');
2728// [4 GB, 16 GB], to avoid triggering swap on memory-constrained machines.
2829const totalMemMB = Math . floor ( require ( 'os' ) . totalmem ( ) / 1024 / 1024 ) ;
2930const rendererMaxOldSpace = Math . min ( 16384 , Math . max ( 4096 , Math . floor ( ( totalMemMB - 2048 ) * 0.75 ) ) ) ;
30- electronApp . commandLine . appendSwitch ( 'js-flags' , `--max-old-space-size=${ rendererMaxOldSpace } ` ) ;
31+ // --expose-gc enables global.gc() in the renderer. The LCC2/HTML export paths
32+ // call (globalThis).gc?.() to release multi-GB intermediate DataTables between
33+ // LOD loads (serializeLcc2FromLodLog, html export). Without the flag those
34+ // hints silently no-op and previous LOD tables stay resident until the next
35+ // multi-GB allocation throws "Array buffer allocation failed".
36+ electronApp . commandLine . appendSwitch ( 'js-flags' , `--expose-gc --max-old-space-size=${ rendererMaxOldSpace } ` ) ;
3137console . log ( `[electron] Renderer V8 heap limit: ${ rendererMaxOldSpace } MB (system RAM: ${ totalMemMB } MB)` ) ;
3238
3339// ---------------------------------------------------------------------------
@@ -173,6 +179,14 @@ async function createWindow() {
173179 console . log ( '[electron] WebGPU check failed:' , e . message ) ;
174180 }
175181
182+ // Forward renderer errors to the main-process terminal so export failures
183+ // can be diagnosed without opening DevTools.
184+ mainWindow . webContents . on ( 'console-message' , ( _e , level , message , line , sourceId ) => {
185+ if ( typeof message === 'string' && level >= 3 ) {
186+ console . log ( `[renderer] ${ message } ${ sourceId ? ` (${ sourceId } :${ line } )` : '' } ` ) ;
187+ }
188+ } ) ;
189+
176190 mainWindow . on ( 'closed' , ( ) => {
177191 mainWindow = null ;
178192 } ) ;
@@ -236,6 +250,30 @@ ipcMain.handle('fs:readFile', async (_event, filePath) => {
236250 return fs . readFileSync ( filePath ) ;
237251} ) ;
238252
253+ // Read a byte range from a file by absolute path + position. Used by the LCC
254+ // reader to stream GB-scale sibling files (data.bin/shcoef.bin) on demand
255+ // WITHOUT going through Chromium's network stack — system proxies / flaky
256+ // localhost routing made fetch-based reads (HTTP Range) unreliable, and IPC
257+ // readFileSync of the whole file blows the renderer's ~4GB memory limit.
258+ ipcMain . handle ( 'fs:readRange' , async ( _event , filePath , position , length ) => {
259+ const fs = require ( 'fs' ) ;
260+ const fd = fs . openSync ( filePath , 'r' ) ;
261+ try {
262+ const buf = Buffer . allocUnsafe ( length ) ;
263+ const bytesRead = fs . readSync ( fd , buf , 0 , length , position ) ;
264+ return buf . subarray ( 0 , bytesRead ) ;
265+ } finally {
266+ fs . closeSync ( fd ) ;
267+ }
268+ } ) ;
269+
270+ // Stat a file; returns { size } (bytes)
271+ ipcMain . handle ( 'fs:stat' , async ( _event , filePath ) => {
272+ const fs = require ( 'fs' ) ;
273+ const s = fs . statSync ( filePath ) ;
274+ return { size : s . size } ;
275+ } ) ;
276+
239277// Check if a file path exists
240278ipcMain . handle ( 'fs:exists' , async ( _event , filePath ) => {
241279 const fs = require ( 'fs' ) ;
@@ -250,9 +288,11 @@ ipcMain.handle('fs:readDir', async (_event, dirPath) => {
250288 return fs . readdirSync ( dirPath ) . map ( name => path . join ( dirPath , name ) ) ;
251289} ) ;
252290
253- // Recursively walk a directory tree. Returns [{ path, rel }] for every regular
254- // file, where `path` is absolute and `rel` is the path relative to `dirPath`
255- // using forward slashes (so it matches web FileSystemEntry fullPath conventions).
291+ // Recursively walk a directory tree. Returns [{ path, rel, size }] for every
292+ // regular file, where `path` is absolute, `rel` is the path relative to `dirPath`
293+ // using forward slashes (so it matches web FileSystemEntry fullPath conventions),
294+ // and `size` is the file size in bytes. The size lets the renderer skip
295+ // GB-scale LCC siblings (data.bin/shcoef.bin) instead of shipping them over IPC.
256296// Used to auto-resolve LCC2 chunk siblings (.sog/.spz) that live in subdirectories
257297// next to a dropped .lcc2 meta file.
258298ipcMain . handle ( 'fs:walk' , async ( _event , dirPath ) => {
@@ -271,7 +311,7 @@ ipcMain.handle('fs:walk', async (_event, dirPath) => {
271311 if ( stat . isDirectory ( ) ) {
272312 walk ( full , rel ) ;
273313 } else if ( stat . isFile ( ) ) {
274- result . push ( { path : full , rel } ) ;
314+ result . push ( { path : full , rel, size : stat . size } ) ;
275315 }
276316 }
277317 } ;
@@ -356,6 +396,16 @@ ipcMain.handle('fs:unlink', async (_event, p) => {
356396 }
357397} ) ;
358398
399+ // Expand a .respproj PLY into a temporary file in the main process. Sending a
400+ // multi-GB project payload to the renderer first creates avoidable heap copies.
401+ ipcMain . handle ( 'project:extractGzipEntry' , async ( _event , projectPath , entryName ) => {
402+ if ( typeof projectPath !== 'string' || typeof entryName !== 'string' || ! / ^ s p l a t _ \d + \. p l y \. g z $ / . test ( entryName ) ) {
403+ throw new Error ( 'Invalid project extraction request' ) ;
404+ }
405+ const tempDir = path . join ( electronApp . getPath ( 'temp' ) , 'ReSplat-projects' ) ;
406+ return extractGzipProjectEntry ( projectPath , entryName , tempDir ) ;
407+ } ) ;
408+
359409// Open URL in system default browser
360410ipcMain . handle ( 'shell:openExternal' , async ( _event , url ) => {
361411 await shell . openExternal ( url ) ;
0 commit comments