Files
dwg-dxf-viewer-sample/src/viewer/objRecenter.ts
T
kimminsungandClaude Opus 4.8 a71790070d 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>
2026-06-19 13:54:46 +09:00

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' });
}