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>
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
/**
|
|
* 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 = "";
|
|
});
|
|
}
|