UNPKG

@vladkrutenyuk/three-kvy-core

Version:

Everything you need to create any-complexity 3D apps with Three.js. Empower Three.js with a modular, lifecycle-managed context that seamlessly propagates through objects via reusable features providing structured logic.

1,024 lines (1,008 loc) 38.9 kB
import { EventEmitter } from 'eventemitter3'; /** * Removes the first occurrence of a specified item from an array. * * @template T - The type of elements in the array. * @param {T[]} array - The array from which to remove the item. * @param {T} item - The item to remove from the array. * @returns {boolean} - Returns `true` if the item was found and removed, otherwise `false`. */ function removeArrayItem(array, item) { const index = array.indexOf(item); const found = index !== -1; if (found) { array.splice(index, 1); } return found; } const defineProps = Object.defineProperties; const readOnly = (value) => ({ value, writable: false, configurable: false, }); const notEnumer = (value) => ({ value, enumerable: false, configurable: true, }); var props = /*#__PURE__*/Object.freeze({ __proto__: null, defineProps: defineProps, notEnumer: notEnumer, readOnly: readOnly }); /** * Traverses the ancestors of a given THREE.Object3D target and applies a callback function to each ancestor. * The traversal is interruptible based on the return value of the callback function. * * @param {THREE.Object3D} target - The starting THREE.Object3D whose ancestors will be traversed. * @param {(ancestor: THREE.Object3D) => boolean} callback - A function that is called with each ancestor. If the callback returns true, the traversal continues; if false, the traversal stops. */ function traverseUp(target, callback) { const parent = target.parent; if (parent !== null) { if (callback(parent)) { traverseUp(parent, callback); } } } const assertDefined = (value, name) => { if (!value) throw new ReferenceError(`Attempted to access '${name}' before it was initialized.`); return value; }; /** * A utility for initializing core [Three.js](https://threejs.org) entities, managing their setup, and handling rendering. * @see {@link https://three-kvy-core.vladkrutenyuk.ru/docs/api/three-context | Official Documentation} * @see {@link https://github.com/vladkrutenyuk/three-kvy-core/blob/main/src/core/ThreeContext.ts | Source} */ class ThreeContext extends EventEmitter { /** * Shortcut to create an instance of ThreeContext. * @param {typeof import("three")} Three - Three.js `THREE` imported module, object containing class constructors `WebGLRenderer`, `PerspectiveCamera`, `Scene`, `Clock`, `Raycaster`. * @param {{renderer: THREE.WebGLRendererParameters}} params - (optional) Object parameters * @returns {ThreeContext} An instance of ThreeContext * @example * ```js import * as THREE from "three"; import * as KVY from "@vladkrutenyuk/three-kvy-core"; const three = new KVY.ThreeContext.create(THREE, { renderer: { antialias: true } }); ``` */ static create(Three, params) { return new ThreeContext(new Three.WebGLRenderer(params === null || params === void 0 ? void 0 : params.renderer), new Three.PerspectiveCamera(), new Three.Scene(), new Three.Clock(), new Three.Raycaster()); } /** * An instance of Three.js `PerspectiveCamera` camera which is used in rendering. Fires event `camerachanged` on set. */ get camera() { return this._camera; } set camera(value) { const prevCamera = this._camera; this._camera = value; this.cameraChanged(value, prevCamera); } /** (readonly) HTML element where the renderer canvas is appended on mount. */ get container() { return this._container; } /** * (readonly) flag to check if the renderer canvas is currently mounted. * @type {boolean} */ get isMounted() { return this._isMounted; } /** * (readonly) flag to check whether this instance has been destroyed. */ get isDestroyed() { return this._isDestroyed; } /** * This creates a new {@link ThreeContext} instance. * @param {THREE.WebGLRenderer} renderer - An instance of Three.js `WebGLRenderer` * @param {THREE.PerspectiveCamera} camera - An instance of Three.js `PerspectiveCamera` * @param {THREE.Scene} scene - An instance of Three.js `Scene` * @param {THREE.Clock} clock - An instance of Three.js `Clock` * @param {THREE.Raycaster} raycaster - An instance of Three.js `Raycaster` */ constructor(renderer, camera, scene, clock, raycaster) { super(); this._container = null; this._resizeObserver = null; this._isMounted = false; this._isDestroyed = false; this._srcRenderFn = () => { this.renderer.render(this.scene, this._camera); }; this._renderFn = this._srcRenderFn; this.resizeHandler = () => { const container = this._container; if (!container) return; const width = container.offsetWidth; const height = container.offsetHeight; const camera = this._camera; camera.aspect = width / height; camera.updateProjectionMatrix(); this.renderer.setSize(width, height); this.emit(ev.Resize, width, height); this.render(); }; defineProps(this, { isThreeContext: readOnly(true) }); this.renderer = renderer; this.scene = scene; this._camera = camera; this.clock = clock; this.raycaster = raycaster; this._renderFn = this._srcRenderFn; } /** * Renders the scene using the current render function. Fires `renderbefore` and `renderafter` events. */ render() { this.emit(ev.RenderBefore); this._renderFn(); this.emit(ev.RenderAfter); } /** * Overrides the render function with a custom implementation. * @param {Function} fn * @returns */ overrideRender(fn) { this._renderFn = fn; return this; } /** * Resets the render function to its default implementation. */ resetRender() { this._renderFn = this._srcRenderFn; return this; } /** * Append the renderer canvas to the given HTML container, initializes event listeners and resize observer. * Fires `mount` event. * @param {HTMLDivElement} container - The HTML container element where to mount (append) renderer canvas. */ mount(container) { if (this._isMounted || this._isDestroyed) return; this._isMounted = true; const canvas = this.renderer.domElement; this._container = container; container.append(canvas); canvas.tabIndex = 0; canvas.style.touchAction = "none"; // canvas.focus(); this.emit(ev.Mount, container); this._resizeObserver = new ResizeObserver(this.resizeHandler); this._resizeObserver.observe(container); this.resizeHandler(); return this; } /** * Remove the renderer canvas from DOM it was mounted, removes event listeners, disconnect resize observer. * Fires `"unmount"` event. */ unmount() { var _a; if (!this._isMounted) return; this._isMounted = false; (_a = this._resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect(); this._resizeObserver = null; this.renderer.domElement.remove(); this.emit(ev.Unmount); return this; } /** * Destroys this instance, releasing resources and preventing further rendering. * Fires `"destroy"` event. */ destroy() { if (this._isDestroyed) return; this._isDestroyed = true; const noRender = () => { console.error("render is called after ThreeContext is destroyed."); }; this._renderFn = noRender; this._srcRenderFn = noRender; this.unmount(); // this.clearScene(true); this.renderer.dispose(); this.emit(ev.Destroy); Object.values(ev).forEach((x) => this.removeAllListeners(x)); } cameraChanged(newCamera, prevCamera) { const camera = this._camera; const root = this._container; if (root) { camera.aspect = root.offsetWidth / root.offsetHeight; } camera.updateProjectionMatrix(); this.emit(ev.CameraChanged, newCamera, prevCamera); } } const ev = Object.freeze({ RenderBefore: "renderbefore", RenderAfter: "renderafter", Mount: "mount", Unmount: "unmount", Destroy: "destroy", CameraChanged: "camerachanged", Resize: "resize", }); const Evnt = Object.freeze({ AttCtx: "attachedctx", DetCtx: "detachedctx", FtAdd: "featureadded", FtRem: "featureremoved", Dstr: "destroy", }); const key$1 = "__kvy_ftblty__"; class Object3DFeaturability extends EventEmitter { /** * Extracts {@link Object3DFeaturability} from the given object if it is featurable. * * @param obj - The object to extract {@link Object3DFeaturability} from. * @returns The {@link Object3DFeaturability} instance if available, otherwise `null`. */ static extract(obj) { const f = obj[key$1]; return f !== undefined && f.isObjectFeaturability ? f : null; } /** * Creates or retrieves {@link Object3DFeaturability} for the given object. * If the object already has featurability, it is returned. Otherwise, a new instance is created. * * @param obj - The object to make featurable. * @returns The {@link Object3DFeaturability} instance for the object. */ static from(obj) { let fblty = Object3DFeaturability.extract(obj); if (fblty) { return fblty; } fblty = new Object3DFeaturability(obj); // fblty.inheritCtx(); return fblty; } static destroy(obj, force) { var _a; (_a = Object3DFeaturability.extract(obj)) === null || _a === void 0 ? void 0 : _a.destroy(force); } get ctx() { return this._ctx; } get features() { return [...this._features]; } constructor(obj) { super(); this.isObjectFeaturability = true; this._ctx = null; this._features = []; this.object = obj; obj.addEventListener("added", this.onObjectAdded); obj.addEventListener("removed", this.onObjectRemoved); defineProps(obj, { [key$1]: notEnumer(this), isFeaturable: notEnumer(true), }); if (obj.parent) { this.onObjectAdded({ target: obj }); } this.inheritCtx(); } /** * Destroys the featurability instance and removes all features. */ destroy(force) { this.destroyAllFeatures(); if (this.object.isRoot && !force) { return; } this.detachCtx(); const obj = this.object; obj.removeEventListener("added", this.onObjectAdded); obj.removeEventListener("removed", this.onObjectRemoved); delete obj[key$1]; delete obj.isFeaturable; //TODO remove all listeners from all } destroyAllFeatures() { const features = this.features; for (const feature of features) { feature.destroy(); } } addFeature(Feature, props, beforeAttach) { const instance = new Feature(this.object, props !== null && props !== void 0 ? props : {}); beforeAttach === null || beforeAttach === void 0 ? void 0 : beforeAttach(instance); this._features.push(instance); instance.init(); this.emit(Evnt.FtAdd, instance); return instance; } /** * Retrieves a feature of a specific class, if present. * @param FeatureClass The feature class to search for. * @returns The feature instance, or `null` if not found. */ getFeature(FeatureClass) { var _a; return ((_a = this._features.find((feature) => feature instanceof FeatureClass)) !== null && _a !== void 0 ? _a : null); } /** * Retrieves a feature of a specific class, if present. * @param FeatureClass The feature class to search for. * @returns The feature instance, or `null` if not found. */ getFeatureBy(predicate) { var _a; return (_a = this._features.find(predicate)) !== null && _a !== void 0 ? _a : null; } /** * Removes a feature from the object and destroys it. * @param feature The feature instance to remove. */ destroyFeature(feature) { this._log(`destroying feature...`); const foundAndRemoved = removeArrayItem(this._features, feature); if (foundAndRemoved) { feature.destroy(); this.emit(Evnt.FtRem, feature); return true; } return false; } /** * Attaches or detaches the object from a `CoreContext`. * @warning You should be careful to use this method manually * @param ctx The `CoreContext` instance, or `null` to detach. * @returns This instance. * @warning Use with caution. */ setCtx(ctx) { if (ctx) { this.attachCtx(ctx); } else { this.detachCtx(); } return this; } onObjectAdded({ target }) { const self = Object3DFeaturability.extract(target); if (!self) { console.error("Object3DFeaturability is not in target object."); return; } self._log("object added"); self.inheritCtx(); } inheritCtx() { var _a; const target = this.object; const parent = target.parent; if (!parent) return; const parentCtx = (_a = Object3DFeaturability.extract(parent)) === null || _a === void 0 ? void 0 : _a._ctx; if (parentCtx) { this._log("onAdded parent has ctx"); this.propagateAttachCtxDown(parentCtx); return; } this._log("onAdded parent is just object3d"); // обрабатываем случай когда GameObject был добавлен к обычному Object3D // ищем предка который был бы IFeaturable let ancestorF = null; //TODO rewrite via stack (while) traverseUp(target, (ancestor) => { ancestorF = Object3DFeaturability.extract(ancestor); return !ancestorF; }); if (ancestorF === null) return; // если нашли и если у него есть мир то аттачимся к нему const ancestorCtx = ancestorF.ctx; this._log("onAdded found featurable object ancestor"); ancestorCtx && this.propagateAttachCtxDown(ancestorCtx); } onObjectRemoved({ target }) { const self = Object3DFeaturability.extract(target); if (!self) { console.error("Object3DFeaturability is not in target object."); return; } self._log("object removed"); self.propagateDetachCtxDown(); } attachCtx(ctx) { this._log("attaching ctx..."); if (this._ctx !== null) { this._log("there is some ctx here"); if (this._ctx !== ctx) { console.error("Cannot attach this object. It had attached to another ctx."); return; } this._log("had attached already"); return; } this._ctx = ctx; this.emit(Evnt.AttCtx, ctx); ctx.once(Evnt.Dstr, this.detachCtx, this); this._log("attached ctx"); } detachCtx() { this._log("detaching ctx..."); if (this._ctx === null) return; const ctx = this._ctx; this._ctx = null; this.emit(Evnt.DetCtx, ctx); ctx.off(Evnt.Dstr, this.detachCtx, this); this._log("detached ctx"); } propagateAttachCtxDown(ctx) { this._log("attaching ctx recursively..."); this.object.traverse((child) => { var _a; (_a = Object3DFeaturability.extract(child)) === null || _a === void 0 ? void 0 : _a.attachCtx(ctx); }); //? shall I use stack instead of recursive traverse? // const stack: THREE.Object3D[] = [this.object]; // while (stack.length > 0) { // const current = stack.pop(); // if (current) { // Object3DFeaturability.extract(current)?.attachCtx(ctx); // stack.push(...current.children); // } } propagateDetachCtxDown() { this._log("detaching ctx recursively..."); this.object.traverse((child) => { var _a; (_a = Object3DFeaturability.extract(child)) === null || _a === void 0 ? void 0 : _a.detachCtx(); }); //? shall I use stack instead of recursive traverse? // const stack: THREE.Object3D[] = [this.object]; // while (stack.length > 0) { // const current = stack.pop(); // if (current) { // Object3DFeaturability.extract(current)?.detachCtx(); // stack.push(...current.children); // } // } } _log(msg) { Object3DFeaturability.log(this, msg); // console.log(`OF-${this.ref.id}-${this.ref.name}`, ...args); } } Object3DFeaturability.log = () => { }; /** * Base class for implementing reusable components (features) that can be added to any Three.js Object3D.\ * Features get the context {@link CoreContext} when their object is added to ctx.root hierarchy, and lose it when removed, or forcibly on call.\ * Handle the context attach and detach can be through overridable lyfecycle method {@link useCtx useCtx(ctx)} where context is providen as arguement.\ * Built-in overridable lifecycle event methods like {@link onBeforeRender onBeforeRender(ctx)} etc. * Direct access to object {@link object this.object} the feature is attached to. * @see {@link https://three-kvy-core.vladkrutenyuk.ru/docs/api/object-3d-feature | Official Documentation} * @see {@link https://github.com/vladkrutenyuk/three-kvy-core/blob/main/src/core/Object3DFeature.ts | Source} */ class Object3DFeature extends EventEmitter { /** * (readonly) Getter for the current attached `CoreContext`. * @warning **Throws exception** if try to access before it is attached. */ get ctx() { return assertDefined(this._ctx, "ctx"); } /** (readonly) Flag to check if this feature has attached `CoreContext`. */ get hasCtx() { return !!this._ctx; } /** * @private Must be initiallized through the `addFeature` static factory method. * @param {IFeaturable<TModules>} object - The object that this feature is going to be attached to. */ constructor(object) { super(); this._ctx = null; this.attachCtx = (ctx) => { // this._log("attachCtx()"); if (this._ctx) { if (this._ctx === ctx) return; // this._log("attachCtx: has ctx"); this.detachCtx(); } this._ctx = ctx; this._log("ctx attached"); this.emit(Evnt.AttCtx, ctx); // this._log("attachCtx() done"); this._log("useCtx"); this._useCtxReturn = this.useCtx(ctx); this.initCtxEventMethods(ctx); }; this.detachCtx = () => { // this._log("detachCtx()"); if (!this._ctx) return; const ctx = this._ctx; this._ctx = null; this.emit(Evnt.DetCtx, ctx); // this._log("detachCtx() done!"); this._log("ctx detached"); if (this._useCtxReturn) { this._log("useCtx cleanup"); this._useCtxReturn(); this._useCtxReturn = undefined; } }; this._ftblty = Object3DFeaturability.extract(object); defineProps(this, { id: readOnly(_featureId++), isObject3DFeature: readOnly(true), object: readOnly(object), }); this.uuid = Object3DFeature.generateUUID(); this._ftblty.on(Evnt.AttCtx, this.ftbltyAttachedToCtxHandler, this); this._ftblty.on(Evnt.DetCtx, this.ftbltyDetachedFromCtxHandler, this); this._log("init"); } /** * Initializes the feature. Called internally after instantiation. * @private */ init() { this._ftblty.ctx && this.attachCtx(this._ftblty.ctx); } /** Destroys this feature instance. */ destroy() { this.detachCtx(); this._ftblty.off(Evnt.AttCtx, this.ftbltyAttachedToCtxHandler, this); this._ftblty.off(Evnt.DetCtx, this.ftbltyDetachedFromCtxHandler, this); const destroyed = this._ftblty.destroyFeature(this); if (destroyed) { this._log("destroyed"); this.emit(Evnt.Dstr); this.onDestroy(); } } ftbltyAttachedToCtxHandler(ctx) { this._log("obj attached to ctx"); this.attachCtx(ctx); } ftbltyDetachedFromCtxHandler(_ctx) { this._log("obj detached from ctx"); this.detachCtx(); } /** * Overridable Lifecycle Method. Called when some `CoreContext` is attached to this feature. * The defined returned cleanup function (optional) is called when the context is detached from the feature. * It is prohibitted to be called manually. * * @param {CoreContext<TModules>} ctx - An instance of `CoreContext` which is attached to this feature. * @returns {undefined | (() => void) | void} A cleanup function, or `undefined` if no cleanup is needed. * @override * * @example * ``` * useCtx(ctx) { * const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial()); * this.object.add(mesh); * * const listener = () => { * // ... * } * const customTicker = ctx.modules.customTicker; * customTicker.on("tick", listener); * * return () => { * this.object.remove(mesh); * mesh.geometry.dispose(); * mesh.material.dispose(); * * customTicker.off("tick", listener); * } * } * ``` */ useCtx(ctx) { return; } /** * When this feature `destroy()` is called. * @override */ onDestroy() { } /** * Before render is called. On each frame after loop run `ctx.run()` or `ctx.three.render()` is called manually. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onBeforeRender(ctx) { } /** * After render is called. On each frame after loop run `ctx.run()` or `ctx.three.render()` is called manually. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onAfterRender(ctx) { } /** * When container (where mounted) is resized. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onResize(ctx) { } /** * When `ctx.three.mount(container)` is called. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onMount(ctx) { } /** * When `ctx.three.unmount()` is called. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onUnmount(ctx) { } /** * When `ctx.run()` is called. * @param {CoreContext<TModules>} ctx - The current attached instance of `CoreContext`. * @override */ onLoopRun(ctx) { } /** * When `ctx.stop()` is called. * @param {CoreContext<TModules>} ctx - The game context. * @override */ onLoopStop(ctx) { } // Debug Logs _log(msg) { Object3DFeature.log(this, msg); // console.log(`F-${this.object.id} ${this.constructor.name}-${this.id}`, ...args); } initCtxEventMethods(ctx) { const p = Object3DFeature.prototype; if (this.onAfterRender !== p.onAfterRender) { this.iehm(ctx.three, "renderafter", "onAfterRender"); } if (this.onBeforeRender !== p.onBeforeRender) { this.iehm(ctx.three, "renderbefore", "onBeforeRender"); } if (this.onMount !== p.onMount) { this.iehm(ctx.three, "mount", "onMount"); } if (this.onResize !== p.onResize) { this.iehm(ctx.three, "unmount", "onUnmount"); } if (this.onLoopRun !== p.onLoopRun) { this.iehm(ctx, "looprun", "onLoopRun"); } if (this.onLoopStop !== p.onLoopStop) { this.iehm(ctx, "loopstop", "onLoopStop"); } } /** * initEventHandlerMethod (iehm) */ iehm(target, type, handlerMethodName) { let listener = null; const subscribe = (ctx) => { listener = function () { this[handlerMethodName](ctx); }; target.on(type, listener, this); }; const unsubscribe = () => { if (!listener) return; target.off(type, listener, this); }; this.on(Evnt.AttCtx, subscribe); this.on(Evnt.DetCtx, unsubscribe); this._ctx && subscribe(this._ctx); } } /** * @returns Generates a unique identifier for [`Object3DFeature`](/docs/) instances.\ * By default, it uses [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID), but in case of fall back, it returns `${Math.random()}-${Date.now()}`. * You can freely override this static method to any of your own generation, e.g: * ```js * CoreContext.generateUUID = () => nanoid(10) * ``` */ Object3DFeature.generateUUID = () => { try { return crypto.randomUUID(); } catch (_a) { return `${Math.random()}-${Date.now()}`; } }; /** * Static method for overriding to handle logs. * @param {Object3DFeature} target - The feature instance. * @param {string} msg - The log message. */ Object3DFeature.log = () => { }; let _featureId = 0; /** * The primary central entity, acting as a main hub, that orchestrates the Three.js environment, animation loop, and module system.\ * Propagates through features `Object3DFeature` which are added to Three.js `Object3D`.\ * Provides an elegant lifecycle management system and handles fundametal initializations. * @see {@link https://three-kvy-core.vladkrutenyuk.ru/docs/api/core-context | Official Documentation} * @see {@link https://github.com/vladkrutenyuk/three-kvy-core/blob/main/src/core/CoreContext.ts | Source} */ class CoreContext extends EventEmitter { /** * Initialization shortcut. Creates and returns a new {@link CoreContext} instance. * @param {typeof import("three")} Three - Object containing Three.js class constructors `WebGLRenderer`, `Scene`, `PerspectiveCamera`, `Clock`, `Raycaster`. In short, just use imported [`THREE`](https://threejs.org/docs/manual/en/introduction/Installation.html) Three.js module. * @param {TModules} modules - (optional) Custom dictionary of any your modules {@link CoreContextModules}. * @param {ThreeContextParams} params - (optional) Object paramateres * @example * ```js * import * as THREE from "three"; * import * as KVY from "@vladkrutenyuk/three-kvy-core"; * * const modules = { * moduleA: new MyModuleA(), * moduleB: new MyModuleB(), * }; * const ctx = KVY.CoreContext.create(THREE, modules, { renderer: { antialias: true } }); * ``` * @returns {CoreContext} */ static create(Three, modules, params) { const three = ThreeContext.create(Three, params); return new CoreContext(three, three.scene, modules); } /** * (readonly) Instance of Three.js `Object3D` that plays the role of entry point for a given context propagation.\ * By default, it's Three.js `Scene` instance given in `ThreeContext` of this (`this.root === this.three.scene`).\ * You can specify any other `root` if you initialize the context through constructor. * @type {THREE.Object3D} * */ get root() { return this._root; } /** (readonly) The seconds passed since the last frame. */ get deltaTime() { return this._deltaTime; } /** (readonly) The seconds passed since the context loop started - by {@link run run()}. */ get time() { return this._time; } /** (readonly) Flag to check if this instance is destroyed. */ get isDestroyed() { return this._isDestroyed; } /** (readonly) Flag to check if this instance loop is running. */ get isRunning() { return this._isRunning; } /** * This creates a new {@link CoreContext} instance. * @param three - An instance of {@link ThreeContext}. Utility to manage Three.js setup. * @param {THREE.Object3D} root - (optional) An instance of Three.js `Object3D`. The entry point for context propagation. If root is not providen then Three.js `Scene` from the given {@link ThreeContext} will be taken as root. * @param {TModules} modules - (optional) Custom dictionary of any your modules {@link CoreContextModules}. */ constructor(three, root, modules) { super(); this._time = 0; this._deltaTime = 0; this._isDestroyed = false; this._isRunning = false; this._cleanups = {}; this._clock = three.clock; defineProps(this, { isCoreContext: readOnly(true), modules: readOnly({}), three: readOnly(three), }); const _root = Object3DFeaturability.from(root !== null && root !== void 0 ? root : three.scene).setCtx(this) .object; _root.isRoot = true; this._root = _root; modules && this.assignModules(modules); } /** Run animation loop and Three.js rendering. Stoppable as many times as you need by `stop()`. */ run() { if (this._isRunning || this._isDestroyed) return; this._isRunning = true; this._clock.start(); this.three.renderer.setAnimationLoop(() => { //! its very important to getDelta() before getElapsedTime() this._deltaTime = this._clock.getDelta(); this._time = this._clock.getElapsedTime(); this.three.render(); }); this.emit("looprun"); } /** Stop animation loop and Three.js rendering. Resumable as many times as you need by `run()`. */ stop() { this._isRunning = false; this._clock.stop(); this.three.renderer.setAnimationLoop(null); this.emit("loopstop"); } /** * Assigns the given dictionary of modules to this instance. * It will be merged with the existing dictionary of modules. * * @remarks Note that if the given dictionary contains a key for which a module is already assigned, * it will be skipped, and a warning message will be fired. * * @param {{ [key: string]: CoreContextModule }} modules - Dictionary of module instances to assign to this context. */ assignModules(modules) { for (const key in modules) { const m = modules[key]; m && this.assignModule(key, m); } return this; } /** * Assign module by key to this instance. * It will be added to the existing dictionary of modules by the given key. * * @remarks Note that if the given key is already assigned, it will be skipped, and a warning message will be fired. * @param {string} key - The key by which to assign the module to the context in the dictionary. * @param {CoreContextModule} module - An instance of `CoreContextModule` implementation. * @returns */ assignModule(key, module) { if (this.modules[key]) { console.warn(`Key [${key.toString()}] is already assinged in modules.`); return; } this.modules[key] = module; const m = module; m._ctx = this; const cleanup = m.useCtx(this); this._cleanups[key] = cleanup; } /** * Remove a module by key that was specified when it was assigned. * @param {string} key - key by which a module was assigned. */ removeModule(key) { const cleanup = this._cleanups[key]; delete this._cleanups[key]; if (cleanup && typeof cleanup === "function") { cleanup(); } const m = this.modules[key]; m._ctx = undefined; delete this.modules[key]; } /** * Destroy this instance. * - Sets {@link isDestroyed} to `true` permanently. * - Stops its animation loop permanently. * - Destroys its {@link three three}: {@link ThreeContext} permanently. * - Fires the `"destroy"` event. * - Cleans up {@link root} from the assigned logic when it was designated as `root` in the given CoreContext. * - Removes all assigned {@link modules}. * - Removes all listeners from its events. */ destroy() { if (this._isDestroyed) return this; this._isDestroyed = true; this.stop(); this.three.destroy(); this.emit("destroy"); Object3DFeaturability.destroy(this._root, true); Object.values(this._cleanups).forEach((fn) => fn && fn()); ["destroy", "looprun", "loopstop"].forEach((x) => this.removeAllListeners(x)); return this; } } /** * Base class, acting as a pluggable module, for extending {@link CoreContext} functionality.\ * It enables clean separation of concerns while maintaining full access to context capabilities.\ * Modules are assigned to context {@link CoreContext}, can provide services to features {@link Object3DFeature}, and manage their own * lifecycle through the {@link useCtx useCtx(ctx)} pattern. * @see {@link https://three-kvy-core.vladkrutenyuk.ru/docs/api/core-context-module | Official Documentation} * @see {@link https://github.com/vladkrutenyuk/three-kvy-core/blob/main/src/core/CoreContextModule.ts | Source} */ class CoreContextModule extends EventEmitter { /** * (readonly) Getter for the instance of {@link CoreContext} this is assigned to. * @warning **Throws exception** if try to access before assign. */ get ctx() { return assertDefined(this._ctx, "ctx"); } /** (readonly) Flag to check if this instance has been assigned to some {@link CoreContext}. */ get hasCtx() { return !!this._ctx; } constructor() { super(); defineProps(this, { isCoreContextModule: readOnly(true) }); } /** * Overridable Lifecycle Method. Called when the module is assigned to a {@link CoreContext}. \ * The returned cleanup function (optional) is called when the module is removed from the context.\ * Also cleanup is called if context is destroyed.\ * Calling the method manually is prohibited. * @param {CoreContext} ctx - An instance of {@link CoreContext} to which this module was assigned. * @returns {Function | undefined} */ useCtx(ctx) { return; } } function addFeature(obj, Feature, props, beforeAttach) { const f = Object3DFeaturability.from(obj); return f.addFeature(Feature, props, beforeAttach); } /** * A static method that retrieves a feature instance from the given object by its class (constructor). Returns first found such feature instance, or `null` if not. * @param {THREE.Object3D} obj - The target Three.js Object3D instance to search for the feature. * @param {typeof Object3DFeature} FeatureClass - The feature class (constructor) whose instance is being searched for. It must extends Object3DFeature. * @returns {Object3DFeature | null} */ function getFeature(obj, FeatureClass) { var _a, _b; return (_b = (_a = Object3DFeaturability.extract(obj)) === null || _a === void 0 ? void 0 : _a.getFeature(FeatureClass)) !== null && _b !== void 0 ? _b : null; } /** * Finds a feature in the given object using a predicate function. Returns the first matching instance of `Object3DFeature` if found, otherwise `null`. * @param {THREE.Object3D} obj - The target Three.js `Object3D` instance to search within. * @param {(feature: Object3DFeature) => boolean} predicate - A predicate function that receives a feature instance as an argument and returns a boolean indicating whether the feature matches. * @returns {Object3DFeature | null} */ function getFeatureBy(obj, predicate) { var _a, _b; return (_b = (_a = Object3DFeaturability.extract(obj)) === null || _a === void 0 ? void 0 : _a.getFeatureBy(predicate)) !== null && _b !== void 0 ? _b : null; } /** * Retrieves all features attached to a given object. Returns a copy of the feature list—an array of `Object3DFeature[]` instances—or `null` if no features were added. * @remarks Note that changing returned array won't affect anything. It returns a **COPY** of this object features list. * @param {THREE.Object3D} obj - The target Three.js `Object3D` instance. * @returns {Object3DFeature[] | null} */ function getFeatures(obj) { var _a, _b; return (_b = (_a = Object3DFeaturability.extract(obj)) === null || _a === void 0 ? void 0 : _a.features) !== null && _b !== void 0 ? _b : null; } /** * Destroys and detaches all features from the given object, freeing associated resources. * If `recursively` is set to `true`, this method will apply cleanup recursively to the entire object hierarchy. * @param {THREE.Object3D} obj - The target Three.js `Object3D` instance. * @param {boolean} recursively - (Optional) Default is `false`. A boolean flag indicating whether to apply this method recursively to the object's hierarchy. */ function clear(obj, recursively) { if (recursively) obj.traverse(Object3DFeaturability.destroy); else Object3DFeaturability.destroy(obj); } //TODO addFeatures(obj, [class MyFeature, { value: 2}], [class AnotherFeature], ... ): [MyFeature, AnotherFeature] const utils = { removeArrayItem, props, traverseUp, assertDefined }; const REVISION = "2.0.0"; const key = "__THREE_KVY_CORE__"; if (typeof window !== "undefined") { if (window[key]) { console.warn("WARNING: Multiple instances of `@vladkrutenyuk/three-kvy-core` being imported."); } else { window[key] = REVISION; } } export { CoreContext, CoreContextModule, Object3DFeaturability, Object3DFeature, REVISION, ThreeContext, addFeature, clear, getFeature, getFeatureBy, getFeatures, utils };