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:
2026-06-19 13:54:46 +09:00
co-authored by Claude Opus 4.8
commit a71790070d
78 changed files with 15921 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* Drag & drop local-file path (PLAN P2-1, P2-2).
*
* Captures HTML5 DnD events + click-to-browse on the #dropzone element,
* validates the picked file is a supported model, and forwards valid files to
* viewer-core's loadLocalFile. createObjectURL/revoke lifecycle is owned by
* the viewer (per the locked Blob URL pattern in SKILL.md); this module only
* validates + hands the File off.
*/
import { extOf } from '../viewer/modelLoader';
/** Viewer surface this module depends on (viewer-core implements loadLocalFile). */
export interface ViewerHandle {
loadLocalFile: (file: File, sidecars?: File[]) => void;
}
/** Options for initDropzone. */
export interface DropzoneOpts {
viewer: ViewerHandle;
dropzone: HTMLElement;
fileInput: HTMLInputElement;
/** Optional error sink; defaults to console.warn. */
onError?: (msg: string) => void;
}
/** Accept any supported model extension by name (case-insensitive). */
function isValidModel(file: File): boolean {
return extOf(file.name) !== null;
}
/**
* Wire up drag/drop + click-to-browse on the given dropzone.
* Idempotent-ish: attaches listeners; caller should call once per element.
*/
export function initDropzone(opts: DropzoneOpts): void {
const { dropzone, fileInput, viewer } = opts;
const onError = opts.onError ?? ((msg: string) => console.warn(msg));
// Accept multiple files at once: the first supported model is the primary,
// the rest (e.g. a .mtl beside an .obj) ride along as sidecars.
const handleFiles = (files: File[]): void => {
if (!files.length) return;
const model = files.find(isValidModel);
if (!model) {
onError("Unsupported file (use .glb .gltf .obj .fbx .dae .ifc)");
return;
}
viewer.loadLocalFile(model, files.filter((f) => f !== model));
};
// --- Drag & drop ---
// preventDefault on dragover is MANDATORY or the browser navigates to the file.
dropzone.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
dropzone.classList.add("drag");
});
dropzone.addEventListener("dragenter", (e: DragEvent) => {
e.preventDefault();
dropzone.classList.add("drag");
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("drag");
});
dropzone.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
dropzone.classList.remove("drag");
handleFiles(Array.from(e.dataTransfer?.files ?? []));
});
// --- Click to browse (accessibility) ---
dropzone.addEventListener("click", () => {
fileInput.click();
});
fileInput.addEventListener("change", () => {
handleFiles(Array.from(fileInput.files ?? []));
// reset so picking the same file twice fires `change` again
fileInput.value = "";
});
}