/** * In-viewer float64 recenter for OBJ files with huge absolute coordinates * (CAD/survey, ~1e8). Done at LOAD time, before OBJLoader quantizes to float32. * * Why here and not after parse: WebGL vertex buffers are float32; at ~1e8 the * float32 ulp is 16-64 units, so vertices snap to that grid, merge, and faces * crack. Subtracting the bbox center from the *source text* (float64) keeps * coords small (~±3e4) so float32 holds full detail — no preprocessed files, * no shader-side double emulation (RTE/two-float), which a single offset of * this magnitude makes unnecessary (relative precision after centering ~3e-5 * vs float32 ~1e-7). * * Streamed in two passes (bbox, then rewrite) to keep memory bounded on the * 100-200MB OBJ files this targets. */ const V = 118; // 'v' const SP = 32; // ' ' const RECENTER_THRESHOLD = 1e4; // models nearer the origin than this are left as-is /** Yield LF-normalized lines from a Blob's stream without buffering the whole file. */ async function* lines(blob: Blob): AsyncGenerator { const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader(); let buf = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += value; let i: number; while ((i = buf.indexOf('\n')) >= 0) { let ln = buf.slice(0, i); if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR yield ln; buf = buf.slice(i + 1); } } if (buf.length) yield buf; } function fmt(n: number): string { return Number.isInteger(n) ? String(n) : String(Math.round(n * 1000) / 1000); } /** * Return an origin-centered copy of `file` as a Blob, or the original `file` * unchanged when it already sits near the origin (small models pay only the * cheap first-pass scan). The returned Blob carries the same OBJ text minus a * per-file integer offset on every `v` line; vn/f/usemtl/mtllib are verbatim. */ export async function recenterObjFile( file: File, onProgress?: (loaded: number, total: number) => void, ): Promise { // Two streamed passes (bbox, then rewrite) over the file's bytes; report // progress across both, throttled so the DOM isn't touched per line. const total = file.size * 2; let read = 0, tick = 0; const report = (line: string): void => { read += line.length + 1; if (onProgress && (++tick & 0x3fff) === 0) onProgress(read, total); // every ~16k lines }; let xmin = Infinity, ymin = Infinity, zmin = Infinity; let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity; for await (const line of lines(file)) { report(line); if (line.charCodeAt(0) !== V || line.charCodeAt(1) !== SP) continue; const p = line.split(/\s+/); const x = +p[1], y = +p[2], z = +p[3]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; if (z < zmin) zmin = z; if (z > zmax) zmax = z; } if (!Number.isFinite(xmin)) return file; // no vertices — let OBJLoader handle it const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2; if (Math.hypot(cx, cy, cz) < RECENTER_THRESHOLD) return file; const ox = Math.round(cx), oy = Math.round(cy), oz = Math.round(cz); const enc = new TextEncoder(); const parts: BlobPart[] = []; let out = ''; for await (const line of lines(file)) { report(line); if (line.charCodeAt(0) === V && line.charCodeAt(1) === SP) { const p = line.split(/\s+/); out += `v ${fmt(+p[1] - ox)} ${fmt(+p[2] - oy)} ${fmt(+p[3] - oz)}\n`; } else { out += line + '\n'; } if (out.length > (1 << 23)) { parts.push(enc.encode(out)); out = ''; } // flush ~8MB } if (out) parts.push(enc.encode(out)); if (onProgress) onProgress(total, total); return new Blob(parts, { type: 'text/plain' }); }