feat: multi-format 3D viewer with large-file OBJ streaming, Z-up, projection/outline UI
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>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
Reference in New Issue
Block a user