UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

643 lines 28.1 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { Box3, Object3D, Vector2, Vector3 } from "three"; import { isDevEnvironment } from "../engine/debug/index.js"; import { AnimationUtils } from "../engine/engine_animation.js"; import { addComponent } from "../engine/engine_components.js"; import { destroy } from "../engine/engine_gameobject.js"; import { Gizmos } from "../engine/engine_gizmos.js"; import { getLoader } from "../engine/engine_gltf.js"; import { BlobStorage } from "../engine/engine_networking_blob.js"; import { PreviewHelper } from "../engine/engine_networking_files.js"; import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { fitObjectIntoVolume, getBoundingBox, placeOnSurface } from "../engine/engine_three_utils.js"; import { getParam, setParamWithoutReload } from "../engine/engine_utils.js"; import { determineMimeTypeFromExtension } from "../engine/engine_utils_format.js"; import { Animation } from "./Animation.js"; import { Behaviour } from "./Component.js"; import { EventList } from "./EventList.js"; /** * Debug mode can be enabled with the URL parameter `?debugdroplistener`, which * logs additional information during drag and drop events and visualizes hit points. */ const debug = getParam("debugdroplistener"); /** * Events dispatched by the DropListener component * @enum {string} */ export var DropListenerEvents; (function (DropListenerEvents) { /** * Dispatched when a file is dropped into the scene. The detail of the event is the {@link File} that was dropped. * The event is called once for each dropped file. */ DropListenerEvents["FileDropped"] = "file-dropped"; /** * Dispatched when a new object is added to the scene. The detail of the event contains {@link DropListenerOnDropArguments} for the content that was added. */ DropListenerEvents["ObjectAdded"] = "object-added"; })(DropListenerEvents || (DropListenerEvents = {})); /** * CustomEvent dispatched when an object is added to the scene via the DropListener */ class DropListenerAddedEvent extends CustomEvent { /** * Creates a new added event with the provided details * @param detail Information about the added object */ constructor(detail) { super(DropListenerEvents.ObjectAdded, { detail }); } } /** * Key name used for blob storage parameters */ const blobKeyName = "blob"; /** The DropListener component is used to listen for drag and drop events in the browser and add the dropped files to the scene * It can be used to allow users to drag and drop glTF files into the scene to add new objects. * * If {@link useNetworking} is enabled, the DropListener will automatically synchronize dropped files to other connected clients. * Enable {@link fitIntoVolume} to automatically scale dropped objects to fit within the volume defined by {@link fitVolumeSize}. * * The following events are dispatched by the DropListener: * - **object-added** - dispatched when a new object is added to the scene * - **file-dropped** - dispatched when a file is dropped into the scene * * @example * ```typescript * import { DropListener, DropListenerEvents } from "@needle-tools/engine"; * * const dropListener = new DropListener(); * * gameObject.addComponent(dropListener); * dropListener.on(DropListenerEvents.FileDropped, (evt) => { * console.log("File dropped", evt.detail); * const file = evt.detail as File; * }); * * dropListener.on(DropListenerEvents.ObjectAdded, (evt) => { * console.log("Object added", evt.detail); * const gltf = evt.detail as GLTF; * }); * ``` * * @category Asset Management * @group Components */ export class DropListener extends Behaviour { /** * When enabled, the DropListener will automatically synchronize dropped files to other connected clients. * When a file is dropped locally, it will be uploaded to blob storage and the URL will be shared with other clients. */ useNetworking = true; /** * When assigned, the DropListener will only accept files that are dropped on this specific object. * This allows creating designated drop zones in your scene. */ dropArea; /** * When enabled, dropped objects will be automatically scaled to fit within the volume defined by fitVolumeSize. * Useful for ensuring dropped models appear at an appropriate scale. * @default false */ fitIntoVolume = false; /** * Defines the dimensions of the volume that dropped objects will be scaled to fit within. * Only used when fitIntoVolume is enabled. */ fitVolumeSize = new Vector3(1, 1, 1); /** * When enabled, dropped objects will be positioned at the point where the cursor hit the scene. * When disabled, objects will be placed at the origin of the DropListener. * @default true */ placeAtHitPosition = true; /** * Event list that gets invoked after a file has been successfully added to the scene. * Receives {@link DropListenerOnDropArguments} containing the added object and related information. * @event object-added * @example * ```typescript * dropListener.onDropped.addEventListener((evt) => { * console.log("Object added", evt.model); * }); */ onDropped = new EventList(); /** @internal */ onEnable() { this.context.renderer.domElement.addEventListener("dragover", this.onDrag); this.context.renderer.domElement.addEventListener("drop", this.onDrop); window.addEventListener("paste", this.handlePaste); this.context.connection.beginListen("droplistener", this.onNetworkEvent); } /** @internal */ onDisable() { this.context.renderer.domElement.removeEventListener("dragover", this.onDrag); this.context.renderer.domElement.removeEventListener("drop", this.onDrop); window.removeEventListener("paste", this.handlePaste); this.context.connection.stopListen("droplistener", this.onNetworkEvent); } /** * Loads a file from the given URL and adds it to the scene. */ loadFromURL(url, data) { this.addFromUrl(url, { screenposition: new Vector2(), point: data?.point, size: data?.size, }, true); } /** * Forgets all previously added objects. * The droplistener will then not be able to remove previously added objects. */ forgetObjects() { this.removePreviouslyAddedObjects(false); } /** * Handles network events received from other clients containing information about dropped objects * @param evt Network event data containing object information, position, and content URL */ onNetworkEvent = (evt) => { if (!this.useNetworking) { if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", evt); return; } if (evt.guid?.startsWith(this.guid)) { const url = evt.url; console.debug("[DropListener] Received networked event", evt); if (url) { if (Array.isArray(url)) { for (const _url of url) { this.addFromUrl(_url, { screenposition: new Vector2(), point: evt.point, size: evt.size, }, true); } } else { this.addFromUrl(url, { screenposition: new Vector2(), point: evt.point, size: evt.size }, true); } } } }; /** * Handles clipboard paste events and processes them as potential URL drops * Only URLs are processed by this handler, and only when editing is allowed * @param evt The paste event */ handlePaste = (evt) => { if (this.context.connection.allowEditing === false) return; if (evt.defaultPrevented) return; const clipboard = navigator.clipboard; clipboard.readText() .then(value => { if (value) { const isUrl = value.startsWith("http") || value.startsWith("https") || value.startsWith("blob"); if (isUrl) { const ctx = { screenposition: new Vector2(this.context.input.mousePosition.x, this.context.input.mousePosition.y) }; if (this.testIfIsInDropArea(ctx)) this.addFromUrl(value, ctx, false); } } }) .catch(console.warn); }; /** * Handles drag events over the renderer's canvas * Prevents default behavior to enable drop events * @param evt The drag event */ onDrag = (evt) => { if (this.context.connection.allowEditing === false) return; // necessary to get drop event evt.preventDefault(); }; /** * Processes drop events to add files to the scene * Handles both file drops and text/URL drops * @param evt The drop event */ onDrop = async (evt) => { if (this.context.connection.allowEditing === false) return; if (debug) console.log(evt); if (!evt?.dataTransfer) return; // If the event is marked as handled for droplisteners then ignore it if (evt["droplistener:handled"]) return; evt.preventDefault(); const ctx = { screenposition: new Vector2(evt.offsetX, evt.offsetY) }; if (this.dropArea) { const res = this.testIfIsInDropArea(ctx); if (res === false) return; } // Don't stop propagation because this will break e.g. the RemoteSkybox drop // evt.stopImmediatePropagation(); // Mark the event handled for droplisteners evt["droplistener:handled"] = true; const items = evt.dataTransfer.items; if (!items) return; const files = []; for (const ite in items) { const it = items[ite]; if (it.kind === "file") { const file = it.getAsFile(); if (!file) continue; files.push(file); } else if (it.kind === "string" && it.type == "text/plain") { it.getAsString(str => { this.addFromUrl(str, ctx, false); }); } } if (files.length > 0) { await this.addDroppedFiles(files, ctx); } }; /** * Processes a dropped or pasted URL and tries to load it as a 3D model * Handles special cases like GitHub URLs and Polyhaven asset URLs * @param url The URL to process * @param ctx Context information about where the drop occurred * @param isRemote Whether this URL was shared from a remote client * @returns The added object or null if loading failed */ async addFromUrl(url, ctx, isRemote) { if (debug) console.log("dropped url", url); try { if (url.startsWith("https://github.com/")) { // make raw.githubusercontent.com url const parts = url.split("/"); const user = parts[3]; const repo = parts[4]; const branch = parts[6]; const path = parts.slice(7).join("/"); url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${path}`; } else if (url.startsWith("https://polyhaven.com/a")) { url = tryResolvePolyhavenAssetUrl(url); } if (!url) return null; // Ignore dropped images const lowercaseUrl = url.toLowerCase(); if (lowercaseUrl.endsWith(".hdr") || lowercaseUrl.endsWith(".hdri") || lowercaseUrl.endsWith(".exr") || lowercaseUrl.endsWith(".png") || lowercaseUrl.endsWith(".jpg") || lowercaseUrl.endsWith(".jpeg")) { return null; } // TODO: if the URL is invalid this will become a problem this.removePreviouslyAddedObjects(); // const binary = await fetch(url).then(res => res.arrayBuffer()); const res = await FileHelper.loadFileFromURL(new URL(url), { guid: this.guid, context: this.context, parent: this.gameObject, point: ctx.point, size: ctx.size, }); if (res && this._addedObjects.length <= 0) { ctx.url = url; const obj = this.addObject(res, ctx, isRemote); return obj; } } catch (_) { console.warn("String is not a valid URL", url); } return null; } _abort = null; /** * Processes dropped files, loads them as 3D models, and handles networking if enabled * Creates an abort controller to cancel previous uploads if new files are dropped * @param fileList Array of dropped files * @param ctx Context information about where the drop occurred */ async addDroppedFiles(fileList, ctx) { if (debug) console.log("Add files", fileList); if (!Array.isArray(fileList)) return; if (!fileList.length) return; this.deleteDropEvent(); this.removePreviouslyAddedObjects(); setParamWithoutReload(blobKeyName, null); // Create an abort controller for the current drop operation this._abort?.abort("New files dropped"); this._abort = new AbortController(); for (const file of fileList) { if (!file) continue; if (file.type.startsWith("image/")) { // Ignore dropped images if (debug) console.warn("Ignoring dropped image file", file.name, file.type); continue; } else if (file.name.endsWith(".bin")) { // Ignore dropped binary files if (debug) console.warn("Ignoring dropped binary file", file.name, file.type); continue; } console.debug("Load file " + file.name + " + " + file.type); const res = await FileHelper.loadFile(file, this.context, { guid: this.guid }); if (res) { this.dispatchEvent(new CustomEvent(DropListenerEvents.FileDropped, { detail: file })); ctx.file = file; const obj = this.addObject(res, ctx, false); // handle uploading the dropped object and networking the event if (obj && this.context.connection.isConnected && this.useNetworking) { console.debug("Uploading dropped file to blob storage"); BlobStorage.upload(file, { abort: this._abort?.signal, }) .then(upload => { // check if the upload was successful and if the object should still be visible if (upload?.download_url && this._addedObjects.includes(obj)) { // setParamWithoutReload(blobKeyName, upload.key); this.sendDropEvent(upload.download_url, obj, res.contentMD5); } }) .catch(console.warn); } // we currently only support dropping one file break; } } } /** Previously added objects */ _addedObjects = new Array(); _addedModels = new Array(); /** * Removes all previously added objects from the scene * @param doDestroy When true, destroys the objects; when false, just clears the references */ removePreviouslyAddedObjects(doDestroy = true) { if (doDestroy) { for (const prev of this._addedObjects) { if (prev.parent === this.gameObject) { destroy(prev, true, true); } } } this._addedObjects.length = 0; this._addedModels.length = 0; } /** * Adds a loaded model to the scene with proper positioning and scaling. * Handles placement based on component settings and raycasting. * If {@link fitIntoVolume} is enabled, the object will be scaled to fit within the volume defined by {@link fitVolumeSize}. * @param data The loaded model data and content hash * @param ctx Context information about where the drop occurred * @param isRemote Whether this object was shared from a remote client * @returns The added object or null if adding failed */ addObject(data, ctx, isRemote) { const { model, contentMD5 } = data; if (debug) console.log(`Dropped ${this.gameObject.name}`, model); if (!model?.scene) { console.warn("No object specified to add to scene", model); return null; } this.removePreviouslyAddedObjects(); const obj = model.scene; // use attach to ignore the DropListener scale (e.g. if the parent object scale is not uniform) this.gameObject.attach(obj); obj.position.set(0, 0, 0); obj.quaternion.identity(); this._addedObjects.push(obj); this._addedModels.push(model); const volume = new Box3().setFromCenterAndSize(new Vector3(0, this.fitVolumeSize.y * .5, 0).add(this.gameObject.worldPosition), this.fitVolumeSize); if (debug) Gizmos.DrawWireBox3(volume, 0x0000ff, 5); if (this.fitIntoVolume) { fitObjectIntoVolume(obj, volume, { position: !this.placeAtHitPosition }); } if (this.placeAtHitPosition && ctx && ctx.screenposition) { obj.visible = false; // < don't raycast on the placed object const rc = this.context.physics.raycast({ screenPoint: this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone()) }); obj.visible = true; if (rc && rc.length > 0) { for (const hit of rc) { const pos = hit.point.clone(); if (debug) console.log("Place object at hit", hit); placeOnSurface(obj, pos); break; } } } AnimationUtils.assignAnimationsFromFile(model, { createAnimationComponent: obj => addComponent(obj, Animation) }); const evt = new DropListenerAddedEvent({ sender: this, gltf: model, model: model, object: obj, contentMD5: contentMD5, dropped: ctx.file || (ctx.url ? new URL(ctx.url) : undefined), }); this.dispatchEvent(evt); this.onDropped?.invoke(evt.detail); // send network event if (!isRemote && ctx.url?.startsWith("http") && this.context.connection.isConnected && obj) { this.sendDropEvent(ctx.url, obj, contentMD5); } return obj; } /** * Sends a network event to other clients about a dropped object * Only triggered when networking is enabled and the connection is established * @param url The URL to the content that was dropped * @param obj The object that was added to the scene * @param contentmd5 The content hash for verification */ async sendDropEvent(url, obj, contentmd5) { if (!this.useNetworking) { if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", url); return; } if (this.context.connection.isConnected) { console.debug("Sending drop event \"" + obj.name + "\"", url); const bounds = getBoundingBox([obj]); const evt = { name: obj.name, guid: this.guid, url, point: obj.worldPosition.clone(), size: bounds.getSize(new Vector3()), contentMD5: contentmd5, }; this.context.connection.send("droplistener", evt); } } /** * Deletes remote state for this DropListener's objects * Called when new files are dropped to clean up previous state */ deleteDropEvent() { this.context.connection.sendDeleteRemoteState(this.guid); } /** * Tests if a drop event occurred within the designated drop area if one is specified * @param ctx The drop context containing screen position information * @returns True if the drop is valid (either no drop area is set or the drop occurred inside it) */ testIfIsInDropArea(ctx) { if (this.dropArea) { const screenPoint = this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone()); const hits = this.context.physics.raycast({ targets: [this.dropArea], screenPoint, recursive: true, testObject: obj => { // Ignore hits on the already added objects, they don't count as part of the dropzone if (this._addedObjects.includes(obj)) return false; return true; } }); if (!hits.length) { if (isDevEnvironment()) console.log(`Dropped outside of drop area for DropListener \"${this.name}\".`); return false; } } return true; } } __decorate([ serializable() ], DropListener.prototype, "useNetworking", void 0); __decorate([ serializable(Object3D) ], DropListener.prototype, "dropArea", void 0); __decorate([ serializable() ], DropListener.prototype, "fitIntoVolume", void 0); __decorate([ serializable(Vector3) ], DropListener.prototype, "fitVolumeSize", void 0); __decorate([ serializable() ], DropListener.prototype, "placeAtHitPosition", void 0); __decorate([ serializable(EventList) ], DropListener.prototype, "onDropped", void 0); /** * Attempts to convert a Polyhaven website URL to a direct glTF model download URL * @param urlStr The original Polyhaven URL * @returns The direct download URL for the glTF model if it's a valid Polyhaven asset URL, otherwise returns the original URL */ function tryResolvePolyhavenAssetUrl(urlStr) { if (!urlStr.startsWith("https://polyhaven.com/")) return urlStr; // Handle dropping polyhaven image url const baseUrl = "https://dl.polyhaven.org/file/ph-assets/Models/gltf/4k/"; const url = new URL(urlStr); const path = url.pathname; const name = path.split("/").pop(); const assetUrl = `${baseUrl}${name}/${name}_4k.gltf`; console.log("Resolved polyhaven asset url", urlStr, "→", assetUrl); // TODO: need to resolve textures properly return assetUrl; } /** * Helper namespace for loading files and models from various sources */ var FileHelper; (function (FileHelper) { /** * Loads and processes a File object into a 3D model * @param file The file to load (supported formats: gltf, glb, fbx, obj, usdz, vrm) * @param context The application context * @param args Additional arguments including a unique guid for instantiation * @returns Promise containing the loaded model and its content hash, or null if loading failed */ async function loadFile(file, context, args) { // first load it locally const seed = args.guid; const prov = new InstantiateIdProvider(seed); const blob = new Blob([file], { type: file.type || determineMimeTypeFromExtension(file.name) || undefined }); const objectUrl = URL.createObjectURL(blob); const model = await getLoader().loadSync(context, objectUrl, file.name, prov).catch(err => { console.error(`Failed to load file "${file.name}" (${file.type}):`, err); return null; }); URL.revokeObjectURL(objectUrl); // clean up the object URL if (model) { return new Promise((resolve, _reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onloadend = async (_ev) => { const content = reader.result; const hash = BlobStorage.hashMD5(content); return resolve({ model, contentMD5: hash }); }; }); } else { console.warn(`Failed to load "${file.name}" (${file.type})`); return null; } } FileHelper.loadFile = loadFile; // return new Promise((resolve, _reject) => { // }); // } /** * Loads a 3D model from a URL with progress visualization * @param url The URL to load the model from * @param args Arguments including context, parent object, and optional placement information * @returns Promise containing the loaded model and its content hash, or null if loading failed */ async function loadFileFromURL(url, args) { return new Promise(async (resolve, _reject) => { const prov = new InstantiateIdProvider(args.guid); const urlStr = url.toString(); if (debug) Gizmos.DrawWireSphere(args.point, .1, 0xff0000, 3); const preview = PreviewHelper.addPreview({ guid: args.guid, parent: args.parent, position: args?.point, size: args?.size, }); const model = await getLoader().loadSync(args.context, urlStr, urlStr, prov, prog => { preview.onProgress(prog.loaded / prog.total); }).catch(console.warn); if (model) { const binary = await fetch(urlStr).then(res => res.arrayBuffer()); const hash = BlobStorage.hashMD5(binary); if (debug) setTimeout(() => PreviewHelper.removePreview(args.guid), 3000); else PreviewHelper.removePreview(args.guid); resolve({ model, contentMD5: hash }); } else { if (debug) setTimeout(() => PreviewHelper.removePreview(args.guid), 3000); else PreviewHelper.removePreview(args.guid); console.warn("Unsupported file type: " + url.toString()); } }); } FileHelper.loadFileFromURL = loadFileFromURL; })(FileHelper || (FileHelper = {})); //# sourceMappingURL=DropListener.js.map