@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
JavaScript
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 };