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.

855 lines (736 loc) • 35.8 kB
import { type Intersection, Mesh, Object3D } from "three"; import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js"; import { type InputEventNames, InputEvents, NEPointerEvent, NEPointerEventIntersection, PointerType } from "../../engine/engine_input.js"; import { onInitialized } from "../../engine/engine_lifecycle_api.js"; import { Mathf } from "../../engine/engine_math.js"; import { RaycastOptions, type RaycastTestObjectReturnType } from "../../engine/engine_physics.js"; import { Context } from "../../engine/engine_setup.js"; import { HideFlags, type IComponent } from "../../engine/engine_types.js"; import { getParam } from "../../engine/engine_utils.js"; import { Behaviour, GameObject } from "../Component.js"; import type { ICanvasGroup } from "./Interfaces.js"; import { hasPointerEventComponent, type IPointerEventHandler, type IPointerUpHandler, PointerEventData } from "./PointerEvents.js"; import { ObjectRaycaster, Raycaster } from "./Raycaster.js"; import { UIRaycastUtils } from "./RaycastUtils.js"; import { $shadowDomOwner } from "./Symbols.js"; import { isUIObject } from "./Utils.js"; const debug = getParam("debugeventsystem"); export enum EventSystemEvents { BeforeHandleInput = "BeforeHandleInput", AfterHandleInput = "AfterHandleInput", } export declare type AfterHandleInputEvent = { sender: EventSystem, args: PointerEventData, hasActiveUI: boolean } declare type IComponentCanMaybeReceiveEvents = IPointerEventHandler & IComponent & { interactable?: boolean }; onInitialized((ctx) => { EventSystem.createIfNoneExists(ctx); }) /** * @category User Interface * @group Components */ export class EventSystem extends Behaviour { //@ts-ignore static ensureUpdateMeshUI(instance, context: Context, force: boolean = false) { MeshUIHelper.update(instance, context, force); } static markUIDirty(_context: Context) { MeshUIHelper.markDirty(); } static createIfNoneExists(context: Context) { if (!context.scene.getComponent(EventSystem)) { context.scene.addComponent(EventSystem); } } static get(ctx: Context): EventSystem | null { this.createIfNoneExists(ctx); return ctx.scene.getComponent(EventSystem); } /** Get the currently active event system */ static get instance(): EventSystem | null { return this.get(Context.Current); } private readonly raycaster: Raycaster[] = []; register(rc: Raycaster) { if (rc && this.raycaster && !this.raycaster.includes(rc)) this.raycaster?.push(rc); } unregister(rc: Raycaster) { const i = this.raycaster?.indexOf(rc); if (i !== undefined && i !== -1) { this.raycaster?.splice(i, 1); } } get hasActiveUI() { return this.currentActiveMeshUIComponents.length > 0; } get isHoveringObjects() { return this.hoveredByID.size > 0; } awake(): void { // We only want ONE eventsystem on the root scene // as long as this component is not implemented in core we need to check this here if (this.gameObject as Object3D !== this.context.scene) { console.debug(`[Needle Engine] EventSystem is only allowed on the scene root. Disabling EventSystem on '${this.gameObject.name}'`); this.enabled = false; } } start() { if (!this.context.scene.getComponent(Raycaster)) { this.context.scene.addComponent(ObjectRaycaster); } } onEnable(): void { this.context.input.addEventListener(InputEvents.PointerDown, this.onPointerEvent); this.context.input.addEventListener(InputEvents.PointerUp, this.onPointerEvent); this.context.input.addEventListener(InputEvents.PointerMove, this.onPointerEvent); } onDisable(): void { this.context.input.removeEventListener(InputEvents.PointerDown, this.onPointerEvent); this.context.input.removeEventListener(InputEvents.PointerUp, this.onPointerEvent); this.context.input.removeEventListener(InputEvents.PointerMove, this.onPointerEvent); } /** * all pointers that have pressed something * * key: pointerId * value: object that was pressed, data of the pointer event, handlers that are releavant to the event */ private pressedByID: Map<number, { obj: Object3D | null, data: PointerEventData, handlers: Set<IPointerEventHandler> }> = new Map(); /** * all hovered objects * * key: pointerId * value: object that is hovered, data of the pointer event */ private hoveredByID: Map<number, { obj: Object3D, data: PointerEventData }> = new Map(); onBeforeRender() { this.resetMeshUIStates(); } /** * Handle an pointer event from the input system */ private onPointerEvent = (pointerEvent: NEPointerEvent) => { if (pointerEvent === undefined) return; if (pointerEvent.propagationStopped) return; if (pointerEvent.defaultPrevented) return; if (pointerEvent.used) return; // Use the pointerID, this is the touch id or the mouse (always 0, could be index of the mouse DEVICE) or the index of the XRInputSource const data = new PointerEventData(this.context.input, pointerEvent); this._currentPointerEventName = pointerEvent.type; data.inputSource = this.context.input; data.isClick = pointerEvent.isClick; data.isDoubleClick = pointerEvent.isDoubleClick; // using the input type directly instead of input API -> otherwise onMove events can sometimes be getPointerUp() == true data.isDown = pointerEvent.type == InputEvents.PointerDown; data.isUp = pointerEvent.type == InputEvents.PointerUp; data.isPressed = this.context.input.getPointerPressed(pointerEvent.pointerId); if (debug) { if (data.isDown) console.log("DOWN", data.pointerId); else if (data.isUp) console.log("UP", data.pointerId); if (data.isClick) console.log("CLICK", data.pointerId); } // raycast const options = new RaycastOptions(); if (pointerEvent.hasRay) { options.ray = pointerEvent.ray; } else { options.screenPoint = this.context.input.getPointerPositionRC(pointerEvent.pointerId)!; } const hits = this.performRaycast(options) as Array<NEPointerEventIntersection>; if (hits) { for (const hit of hits) { hit.event = pointerEvent; pointerEvent.intersections.push(hit); } if (pointerEvent.origin.onPointerHits) { pointerEvent.origin.onPointerHits({ sender: this, event: pointerEvent, hits }); } } if (debug && data.isClick) { showBalloonMessage("EventSystem: " + data.pointerId + " - " + this.context.time.frame + " - Up:" + data.isUp + ", Down:" + data.isDown) } const evt: AfterHandleInputEvent = { sender: this, args: data, hasActiveUI: this.currentActiveMeshUIComponents.length > 0, } this.dispatchEvent(new CustomEvent(EventSystemEvents.BeforeHandleInput, { detail: evt })); // then handle the intersections and call the callbacks on the regular objects this.handleIntersections(hits, data); this.dispatchEvent(new CustomEvent<AfterHandleInputEvent>(EventSystemEvents.AfterHandleInput, { detail: evt })); } private readonly _sortedHits: Intersection[] = []; /** * cache for objects that we want to raycast against. It's cleared before each call to performRaycast invoking raycasters */ private readonly _testObjectsCache = new Map<Object3D, boolean>(); /** that's the raycaster that is CURRENTLY being used for raycasting (the shouldRaycastObject method uses this) */ private _currentlyActiveRaycaster: Raycaster | null = null; private _currentPointerEventName: InputEventNames | null = null; /** * Checks if an object that we encounter has an event component and if it does, we add it to our objects cache * If it doesnt we tell our raycasting system to ignore it and continue in the child hierarchy * We do this to avoid raycasts against objects that are not going to be used by the event system * Because there's no component callback to be invoked anyways. * This is especially important to avoid expensive raycasts against SkinnedMeshes * * Further optimizations would be to check what type of event we're dealing with * For example if an event component has only an onPointerClick method we don't need to raycast during movement events * */ private shouldRaycastObject = (obj: Object3D): RaycastTestObjectReturnType => { // TODO: this implementation below should be removed and we should regularly raycast objects in the scene unless marked as "do not raycast" // with the introduction of the mesh-bvh based raycasting the performance impact should be greatly reduced. But this needs further testing const raycasterOnObject = obj && "getComponent" in obj ? obj.getComponent(Raycaster) : null; if (raycasterOnObject && raycasterOnObject != this._currentlyActiveRaycaster) { return false; } // if (this._currentPointerEventName == "pointermove") { // console.log(this.context.time.frame, obj.name, obj.type, obj.guid) // } // check if this object is actually a UI shadow hierarchy object let uiOwner: Object3D | null = null; const isUI = isUIObject(obj); // if yes we want to grab the actual object that is the owner of the shadow dom // and check that object for the event component if (isUI) { uiOwner = obj[$shadowDomOwner]?.gameObject; } // check if the object was seen previously if (this._testObjectsCache.has(obj) || (uiOwner && this._testObjectsCache.has(uiOwner))) { // if yes we check if it was previously stored as "YES WE NEED TO RAYCAST THIS" const prev = this._testObjectsCache.get(obj)!; if (prev === false) return "continue in children" return true; } else { // if the object has another raycaster component than the one that is currently raycasting, we ignore this here // because then this other raycaster is responsible for raycasting this object // const rc = GameObject.getComponent(obj, Raycaster); // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return false; // the object was not yet seen so we test if it has an event component let hasEventComponent = hasPointerEventComponent(obj, this._currentPointerEventName); if (!hasEventComponent && uiOwner) hasEventComponent = hasPointerEventComponent(uiOwner, this._currentPointerEventName); if (hasEventComponent) { // it has an event component: we add it and all its children to the cache // we don't need to do the same for the shadow component hierarchy // because the next object that will be detecting that the shadow owner was already seen this._testObjectsCache.set(obj, true); for (const ch of obj.children) this.shouldRaycastObject_AddToYesCache(ch); return true; } this._testObjectsCache.set(obj, false); return "continue in children" } } private shouldRaycastObject_AddToYesCache(obj: Object3D) { // if the object has another raycaster component than the one that is currently raycasting, we ignore this here // because then this other raycaster is responsible for raycasting this object // const rc = GameObject.getComponent(obj, Raycaster); // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return; this._testObjectsCache.set(obj, true); for (const ch of obj.children) { this.shouldRaycastObject_AddToYesCache(ch); } } /** the raycast filter is always overriden */ private performRaycast(opts: RaycastOptions | null): Intersection[] | null { if (!this.raycaster) return null; // we clear the cache of previously seen objects this._testObjectsCache.clear(); this._sortedHits.length = 0; if (!opts) opts = new RaycastOptions(); opts.testObject = this.shouldRaycastObject; for (const rc of this.raycaster) { if (!rc.activeAndEnabled) continue; this._currentlyActiveRaycaster = rc; const res = rc.performRaycast(opts); this._currentlyActiveRaycaster = null; if (res && res.length > 0) { // console.log(res.length, res.map(r => r.object.name)); this._sortedHits.push(...res); } } this._sortedHits.sort((a, b) => { return a.distance - b.distance; }); return this._sortedHits; } private assignHitInformation(args: PointerEventData, hit?: Intersection) { if (!hit) { args.intersection = undefined; args.point = undefined; args.normal = undefined; args.face = undefined; args.distance = undefined; args.instanceId = undefined; } else { args.intersection = hit; args.point = hit.point; args.normal = hit.normal; args.face = hit.face; args.distance = hit.distance; args.instanceId = hit.instanceId; } } private handleIntersections(hits: Intersection[] | null | undefined, args: PointerEventData): boolean { if (hits?.length) { hits = this.sortCandidates(hits); for (const hit of hits) { if (args.event.immediatePropagationStopped) { return false; } this.assignHitInformation(args, hit); if (this.handleEventOnObject(hit.object, args)) { return true; } } } // first invoke captured pointers this.assignHitInformation(args, hits?.[0]); this.invokePointerCapture(args); // pointer has not hit any object to handle // thus is not hovering over anything const hoveredData = this.hoveredByID.get(args.pointerId); if (hoveredData) { this.propagatePointerExit(hoveredData.obj, hoveredData.data, null); } this.hoveredByID.delete(args.pointerId); // if it was up, it means it should notify things that it down on before if (args.isUp) { this.pressedByID.get(args.pointerId)?.handlers.forEach(h => this.invokeOnPointerUp(args, h)); this.pressedByID.delete(args.pointerId); } return false; } private _sortingBuffer: Intersection[] = []; private _noDepthTestingResults: Intersection[] = []; private sortCandidates(hits: Intersection[]): Intersection[] { // iterate over all hits and filter for nodepth objects and normal hit objects // the no-depth objects will be handled first starting from the closest // assuming the hits array is sorted by distance (closest > furthest) this._sortingBuffer.length = 0; this._noDepthTestingResults.length = 0; for (let i = 0; i < hits.length; i++) { const hit = hits[i]; const object = hit.object as Mesh; if (object.material) { if (object.material["depthTest"] === false) { this._noDepthTestingResults.push(hit); continue; } } this._sortingBuffer.push(hit); } for (const obj of this._sortingBuffer) { this._noDepthTestingResults.push(obj); } return this._noDepthTestingResults; } private out: { canvasGroup?: ICanvasGroup } = {}; /** * Handle hit result by preparing all needed information before propagation. * Then calling propagate. */ private handleEventOnObject(object: Object3D, args: PointerEventData): boolean { // ensures that invisible objects are ignored if (!this.testIsVisible(object)) { if (args.isClick && debug) console.log("not allowed", object); return false; } // Event without pointer can't be handled if (args.pointerId === undefined) { if (debug) console.error("Event without pointer can't be handled", args); return false; } // Correct the handled object to match the relevant object in shadow dom (?) args.object = object; const parent = object.parent as any; let isShadow = false; const clicked = args.isClick ?? false; let canvasGroup: ICanvasGroup | null = null; // handle potential shadow dom built from three mesh ui if (parent && parent.isUI) { const pressedOrClicked = (args.isPressed || args.isClick) ?? false; if (parent[$shadowDomOwner]) { const actualGo = parent[$shadowDomOwner].gameObject; if (actualGo) { const res = UIRaycastUtils.isInteractable(actualGo, this.out); if (!res) return false; canvasGroup = this.out.canvasGroup ?? null; const handled = this.handleMeshUIIntersection(object, pressedOrClicked); if (!clicked && handled) { // return true; } object = actualGo; isShadow = true; } } // adding this to have a way for allowing to receive events on TMUI elements without shadow hierarchy // if(parent["needle:use_eventsystem"] == true){ // // if use_eventsystem is true, we want to handle the event // } // else if (!isShadow) { // const obj = this.handleMeshUiObjectWithoutShadowDom(parent, pressedOrClicked); // if (obj) return true; // } } if (clicked && debug) console.log(this.context.time.frame, object); // Handle OnPointerExit -> in case when we are about to hover something new // TODO: we need to keep track of the components that already received a PointerEnterEvent -> we can have a hierarchy where the hovered object changes but the component is on the parent and should not receive a PointerExit event because it's still hovered (just another child object) const hovering = this.hoveredByID.get(args.pointerId); const prevHovering = hovering?.obj; const isNewlyHovering = prevHovering !== object; // trigger onPointerExit if (isNewlyHovering && prevHovering) { this.propagatePointerExit(prevHovering, hovering.data, object); } // save hovered object const entry = this.hoveredByID.get(args.pointerId); if (!entry) this.hoveredByID.set(args.pointerId, { obj: object, data: args }); else { entry.obj = object; entry.data = args; } // create / update pressed entry if (args.isDown) { const data = this.pressedByID.get(args.pointerId); if (!data) this.pressedByID.set(args.pointerId, { obj: object, data: args, handlers: new Set<IPointerEventHandler>() }); else { data.obj = object; data.data = args; } } if (canvasGroup === null || canvasGroup.interactable) { this.handleMainInteraction(object, args, prevHovering ?? null); } return true; } /** * Propagate up in hiearchy and call the callback for each component that is possibly a handler */ private propagate(object: Object3D | null, onComponent: (behaviour: Behaviour) => void) { while (true) { if (!object) break; GameObject.foreachComponent(object, comp => { // TODO: implement Stop Immediate Propagation onComponent(comp); }, false); // walk up object = object.parent; } } /** * Propagate up in hierarchy and call handlers based on the pointer event data */ private handleMainInteraction(object: Object3D, args: PointerEventData, prevHovering: Object3D | null) { const pressedEvent = this.pressedByID.get(args.pointerId); const hoveredObjectChanged = prevHovering !== object; // TODO: should we not move this check up before we even raycast for "pointerMove" events? We dont need to do any processing if the pointer didnt move let isMoving = true; switch (args.event.pointerType) { case "mouse": case "touch": const posLastFrame = this.context.input.getPointerPositionLastFrame(args.pointerId!)!; const posThisFrame = this.context.input.getPointerPosition(args.pointerId!)!; isMoving = posLastFrame && !Mathf.approximately(posLastFrame, posThisFrame); break; case "controller": case "hand": // for hands and controller we assume they are never totally still (except for simulated environments) // we might want to add a threshold here (e.g. if a user holds their hand very still or controller) // so maybe check the angle every frame? break; } this.propagate(object, (behaviour) => { const comp = behaviour as IComponentCanMaybeReceiveEvents; if (comp.interactable === false) return; if (!comp.activeAndEnabled || !comp.enabled) return; if (comp.onPointerEnter) { if (hoveredObjectChanged) { this.handlePointerEnter(comp, args); } } if (args.isDown) { if (comp.onPointerDown) { comp.onPointerDown(args); // Set the handler that we called the down event on // So we can call the up event on the same handler // In a scenario where we Down on one object and Up on another pressedEvent?.handlers.add(comp); this.handlePointerCapture(args, comp); } } if (comp.onPointerMove) { if (isMoving) comp.onPointerMove(args); this.handlePointerCapture(args, comp); } if (args.isUp) { if (comp.onPointerUp) { this.invokeOnPointerUp(args, comp); // We don't want to call Up twice if we Down and Up on the same object // But if we Down on one and Up on another we want to call Up on the first one as well // For example if the object was cloned by the Duplicatable // The original component that received the down event SHOULD also receive the up event pressedEvent?.handlers.delete(comp); } // handle touch onExit (touchUp) since the pointer stops existing // mouse onExit (mouseUp) is handled when we hover over something else / on nothing // Mouse 0 is always persistent if (comp.onPointerExit && args.event?.pointerType === PointerType.Touch) { this.handlePointerExit(comp, args); this.hoveredByID.delete(args.pointerId!); } } if (args.isClick) { if (comp.onPointerClick) { comp.onPointerClick(args); } } }); // after the propagation end, call UP on any objects that were DOWNED and didn't recieve an UP while propagating // If user drags away from the object, then it doesn't get the UP event if (args.isUp) { pressedEvent?.handlers.forEach((handler) => { this.invokeOnPointerUp(args, handler); }); this.pressedByID.delete(args.pointerId); } } /** Propagate up in hierarchy and call onPointerExit */ private propagatePointerExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) { this.propagate(object, (behaviour) => { if (!behaviour.gameObject || behaviour.destroyed) return; const inst: any = behaviour; if (inst.onPointerExit || inst.onPointerEnter) { // if the newly hovered object is a child of the current object, we don't want to call onPointerExit if (newObject && this.isChild(newObject, behaviour.gameObject)) { return; } this.handlePointerExit(inst, args); } }); } /** handles onPointerUp - this will also release the pointerCapture */ private invokeOnPointerUp(evt: PointerEventData, handler: IPointerUpHandler) { handler.onPointerUp?.call(handler, evt); this.releasePointerCapture(evt, handler); } /** Responsible for invoking onPointerEnter (and updating onPointerExit). We invoke onPointerEnter once per active pointerId */ private handlePointerEnter(comp: IComponentCanMaybeReceiveEvents, args: PointerEventData) { if (comp.onPointerEnter) { if (this.updatePointerState(comp, args.pointerId, this.pointerEnterSymbol, true)) { comp.onPointerEnter(args); } } this.updatePointerState(comp, args.pointerId, this.pointerExitSymbol, false); } /** Responsible for invoking onPointerExit (and updating onPointerEnter). We invoke onPointerExit once per active pointerId */ private handlePointerExit(comp: IComponentCanMaybeReceiveEvents, evt: PointerEventData) { if (comp.onPointerExit) { if (this.updatePointerState(comp, evt.pointerId, this.pointerExitSymbol, true)) { comp.onPointerExit(evt); } } this.updatePointerState(comp, evt.pointerId, this.pointerEnterSymbol, false); } /** updates the pointer state list for a component * @param comp the component to update * @param pointerId the pointerId to update * @param symbol the symbol to use for the state * @param add if true, the pointerId is added to the state list, if false the pointerId will be removed */ private updatePointerState(comp: IComponentCanMaybeReceiveEvents, pointerId: number, symbol: symbol, add: boolean) { let state = comp[symbol]; if (add) { // the pointer is already in the state list if (state && state.includes(pointerId)) return false; state = state || []; state.push(pointerId); comp[symbol] = state; return true; } else { if (!state || !state.includes(pointerId)) return false; const i = state.indexOf(pointerId); if (i !== -1) { state.splice(i, 1); } return true; } } /** the list of component handlers that requested pointerCapture for a specific pointerId */ private readonly _capturedPointer: { [id: number]: IPointerEventHandler[] } = {}; /** check if the event was marked to be captured: if yes add the current component to the captured list */ private handlePointerCapture(evt: PointerEventData, comp: IPointerEventHandler) { if (evt.z__pointer_ctured) { evt.z__pointer_ctured = false; const id = evt.pointerId; // only the onPointerMove event is called with captured pointers so we don't need to add it to our list if it doesnt implement onPointerMove if (comp.onPointerMove) { const list = this._capturedPointer[id] || []; list.push(comp); this._capturedPointer[id] = list; } else { if (isDevEnvironment() && !comp["z__warned_no_pointermove"]) { comp["z__warned_no_pointermove"] = true; console.warn("PointerCapture was requested but the component doesn't implement onPointerMove. It will not receive any pointer events"); } } } else if (evt.z__pointer_cture_rleased) { evt.z__pointer_cture_rleased = false; this.releasePointerCapture(evt, comp); } } /** removes the component from the pointer capture list */ releasePointerCapture(evt: PointerEventData, component: IPointerEventHandler) { const id = evt.pointerId; if (this._capturedPointer[id]) { const i = this._capturedPointer[id].indexOf(component); if (i !== -1) { this._capturedPointer[id].splice(i, 1); if (debug) console.log("released pointer capture", id, component, this._capturedPointer) } } } /** invoke the pointerMove event on all captured handlers */ private invokePointerCapture(evt: PointerEventData) { if (evt.event.type === InputEvents.PointerMove) { const id = evt.pointerId; const captured = this._capturedPointer[id]; if (captured) { if (debug) console.log("Captured", id, captured) for (let i = 0; i < captured.length; i++) { const handler = captured[i]; // check if it was destroyed const comp = handler as IComponent; if (comp.destroyed) { captured.splice(i, 1); i--; continue; } // invoke pointer move handler.onPointerMove?.call(handler, evt); } } } } private readonly pointerEnterSymbol = Symbol("pointerEnter"); private readonly pointerExitSymbol = Symbol("pointerExit"); private isChild(obj: Object3D, possibleChild: Object3D): boolean { if (!obj || !possibleChild) return false; if (obj === possibleChild) return true; if (!obj.parent) return false; return this.isChild(obj.parent, possibleChild); } private handleMeshUiObjectWithoutShadowDom(obj: any, pressed: boolean) { if (!obj || !obj.isUI) return true; const hit = this.handleMeshUIIntersection(obj, pressed); return hit; } private currentActiveMeshUIComponents: Object3D[] = []; private handleMeshUIIntersection(meshUiObject: Object3D, pressed: boolean): boolean { const res = MeshUIHelper.updateState(meshUiObject, pressed); if (res) { this.currentActiveMeshUIComponents.push(res); } return res !== null; } private resetMeshUIStates() { if (this.context.input.getPointerPressedCount() > 0) { MeshUIHelper.resetLastSelected(); } if (!this.currentActiveMeshUIComponents || this.currentActiveMeshUIComponents.length <= 0) return; for (let i = 0; i < this.currentActiveMeshUIComponents.length; i++) { const comp = this.currentActiveMeshUIComponents[i]; MeshUIHelper.resetState(comp); } this.currentActiveMeshUIComponents.length = 0; } private testIsVisible(obj: Object3D | null): boolean { if (!obj) return true; if (!GameObject.isActiveSelf(obj)) return false; return this.testIsVisible(obj.parent); } } class MeshUIHelper { private static lastSelected: Object3D | null = null; private static lastUpdateFrame: { context: Context, frame: number, nextUpdate: number }[] = []; private static needsUpdate: boolean = false; static markDirty() { this.needsUpdate = true; } static update(threeMeshUI: any, context: Context, force: boolean = false) { if (force) { threeMeshUI.update(); return; } const currentFrame = context.time.frameCount; for (const lu of this.lastUpdateFrame) { if (lu.context === context) { if (currentFrame === lu.frame) return; lu.frame = currentFrame; let shouldUpdate = this.needsUpdate || currentFrame < 1; if (lu.nextUpdate <= currentFrame) shouldUpdate = true; // if (this.needsUpdate) lu.nextUpdate = currentFrame + 3; if (shouldUpdate) { // console.warn(currentFrame, lu.nextUpdate, this.needsUpdate) if (debug) console.log("Update threemeshui"); this.needsUpdate = false; lu.nextUpdate = currentFrame + 60; threeMeshUI.update(); } return; } } this.lastUpdateFrame = [{ context, frame: currentFrame, nextUpdate: currentFrame + 60 }]; threeMeshUI.update(); this.needsUpdate = false; } static updateState(intersect: Object3D, _selectState: boolean): Object3D | null { let foundBlock: Object3D | null = null; if (intersect) { foundBlock = this.findBlockOrTextInParent(intersect); // console.log(intersect, "-- found block:", foundBlock) if (foundBlock && foundBlock !== this.lastSelected) { const interactable = foundBlock["interactable"]; if (interactable === false) return null; this.needsUpdate = true; } } return foundBlock; } static resetLastSelected() { const last = this.lastSelected; if (!last) return; this.lastSelected = null; this.resetState(last); } static resetState(obj: any) { if (!obj) return; this.needsUpdate = true; } static findBlockOrTextInParent(elem: any): Object3D | null { if (!elem) return null; if (elem.isBlock || (elem.isText)) { // @TODO : Replace states managements // if (Object.keys(elem.states).length > 0) return elem; } return this.findBlockOrTextInParent(elem.parent); } }