Three.js viewer supporting glb/gltf/obj/fbx/dae/ifc/ply via both server (SSR) and drag&drop (CSR) paths. - Streaming OBJ parser (src/viewer/objStream.ts) for files past the V8 max string length (>~1GB text) that OBJLoader can't handle; indexed geometry, per-vertex color from MTL Kd, float64 recenter baked in. - In-viewer float64 recenter (objRecenter.ts) for huge CAD/survey coordinates (~1e8) so float32 vertex buffers keep precision (no cracked faces). - Z-up right-handed world; Y-up formats rotated on load. - OBJ+MTL+texture drag&drop (LoadingManager URL-modifier maps dropped images). - OrbitControls ground-plane panning (road/rail alignment workflow). - UI: Zoom Fit, perspective/orthographic toggle, feature-edge outline. - DoubleSide for CAD OBJ; PLY mesh + point-cloud support. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<Blob> {
|
|
// 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' });
|
|
}
|