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.

514 lines • 19.6 kB
import { Mesh, Object3D, TextureLoader, Vector4 } from "three"; import ThreeMeshUI from "three-mesh-ui"; import { addNewComponent } from "../../engine_components.js"; import { hasProLicense } from "../../engine_license.js"; import { OneEuroFilterXYZ } from "../../engine_math.js"; import { lookAtObject } from "../../engine_three_utils.js"; import { TypeStore } from "../../engine_typestore.js"; import { DeviceUtilities, getParam } from "../../engine_utils.js"; import { getIconTexture, isIconElement } from "../icons.js"; const debug = getParam("debugspatialmenu"); export class NeedleSpatialMenu { _context; needleMenu; htmlButtonsMap = new Map(); enabled = true; constructor(context, menu) { this._context = context; this._context.pre_render_callbacks.push(this.preRender); this.needleMenu = menu; const optionsContainer = this.needleMenu.shadowRoot?.querySelector(".options"); if (!optionsContainer) { console.error("Could not find options container in needle menu"); } else { const watcher = new MutationObserver((mutations) => { if (!this.enabled) return; if (this._context.isInXR == false && !debug) return; for (const mutation of mutations) { if (mutation.type === "childList") { mutation.addedNodes.forEach((node) => { this.createButtonFromHTMLNode(node); }); mutation.removedNodes.forEach((node) => { const button = node; const spatialButton = this.htmlButtonsMap.get(button); if (spatialButton) { this.htmlButtonsMap.delete(button); spatialButton.remove(); ThreeMeshUI.update(); } }); } } }); watcher.observe(optionsContainer, { childList: true }); } } setEnabled(enabled) { this.enabled = enabled; if (!enabled) this.menu?.removeFromParent(); } userRequestedMenu = false; /** Bring up the spatial menu. This is typically invoked from a button click. * The menu will show at a lower height to be easily accessible. * @returns true if the menu was shown, false if it can't be shown because the menu has been disabled. */ setDisplay(display) { if (!this.enabled) return false; this.userRequestedMenu = display; return true; } onDestroy() { const index = this._context.pre_render_callbacks.indexOf(this.preRender); if (index > -1) { this._context.pre_render_callbacks.splice(index, 1); } } uiisDirty = false; markDirty() { this.uiisDirty = true; } _showNeedleLogo; showNeedleLogo(show) { this._showNeedleLogo = show; } _wasInXR = false; preRender = () => { if (!this.enabled) { this.menu?.removeFromParent(); return; } if (debug && DeviceUtilities.isDesktop()) { this.updateMenu(); } const xr = this._context.xr; if (!xr?.running) { if (this._wasInXR) { this._wasInXR = false; this.onExitXR(); } return; } if (!this._wasInXR) { this._wasInXR = true; this.onEnterXR(); } this.updateMenu(); }; onEnterXR() { const nodes = this.needleMenu.shadowRoot?.querySelector(".options"); if (nodes) { nodes.childNodes.forEach((node) => { this.createButtonFromHTMLNode(node); }); } } onExitXR() { this.menu?.removeFromParent(); } createButtonFromHTMLNode(node) { const menu = this.getMenu(); const existing = this.htmlButtonsMap.get(node); if (existing) { existing.add(); return; } if (node instanceof HTMLButtonElement) { const spatialButton = this.createButton(menu, node); this.htmlButtonsMap.set(node, spatialButton); spatialButton.add(); } else if (node instanceof HTMLSlotElement) { node.assignedNodes().forEach((node) => { this.createButtonFromHTMLNode(node); }); } } _menuTarget = new Object3D(); positionFilter = new OneEuroFilterXYZ(90, .5); updateMenu() { //performance.mark('NeedleSpatialMenu updateMenu start'); const menu = this.getMenu(); this.handleNeedleWatermark(); this._context.scene.add(menu); const camera = this._context.mainCamera; const xr = this._context.xr; const rigScale = xr?.rigScale || 1; if (camera) { const menuTargetPosition = camera.worldPosition; const fwd = camera.worldForward.multiplyScalar(-1); const showMenuThreshold = fwd.y > .6; const hideMenuThreshold = fwd.y > .4; const newVisibleState = (menu.visible ? hideMenuThreshold : showMenuThreshold) || this.userRequestedMenu; const becomesVisible = !menu.visible && newVisibleState; menu.visible = newVisibleState || (DeviceUtilities.isDesktop() && debug); fwd.multiplyScalar(3 * rigScale); menuTargetPosition.add(fwd); const testBecomesVisible = false; // this._context.time.frame % 200 == 0; if (becomesVisible || testBecomesVisible) { menu.position.copy(this._menuTarget.position); menu.position.y += 0.25; this._menuTarget.position.copy(menu.position); this.positionFilter.reset(menu.position); menu.quaternion.copy(this._menuTarget.quaternion); this.markDirty(); } const distFromForwardView = this._menuTarget.position.distanceTo(menuTargetPosition); if (becomesVisible || distFromForwardView > 1.5 * rigScale) { this.ensureRenderOnTop(this.menu); this._menuTarget.position.copy(menuTargetPosition); this._context.scene.add(this._menuTarget); lookAtObject(this._menuTarget, this._context.mainCamera, true, true); this._menuTarget.removeFromParent(); } this.positionFilter.filter(this._menuTarget.position, menu.position, this._context.time.time); const step = 5; this.menu?.quaternion.slerp(this._menuTarget.quaternion, this._context.time.deltaTime * step); this.menu?.scale.setScalar(rigScale); } if (this.uiisDirty) { //performance.mark('SpatialMenu.update.uiisDirty.start'); this.uiisDirty = false; ThreeMeshUI.update(); //performance.mark('SpatialMenu.update.uiisDirty.end'); //performance.measure('SpatialMenu.update.uiisDirty', 'SpatialMenu.update.uiisDirty.start', 'SpatialMenu.update.uiisDirty.end'); } //performance.mark('NeedleSpatialMenu updateMenu end'); //performance.measure('SpatialMenu.update', 'NeedleSpatialMenu updateMenu start', 'NeedleSpatialMenu updateMenu end'); } ensureRenderOnTop(obj, level = 0) { if (obj instanceof Mesh) { obj.material.depthTest = false; obj.material.depthWrite = false; } obj.renderOrder = 1000 + level * 2; for (const child of obj.children) { this.ensureRenderOnTop(child, level + 1); } } familyName = "Needle Spatial Menu"; menu; get isVisible() { return this.menu?.visible; } getMenu() { if (this.menu) { return this.menu; } this.ensureFont(); this.menu = new ThreeMeshUI.Block({ boxSizing: 'border-box', fontFamily: this.familyName, height: "auto", fontSize: .1, color: 0x000000, lineHeight: 1, backgroundColor: 0xffffff, backgroundOpacity: .55, borderRadius: 1.0, whiteSpace: 'pre-wrap', flexDirection: 'row', alignItems: 'center', padding: new Vector4(.0, .05, .0, .05), borderColor: 0x000000, borderOpacity: .05, borderWidth: .005 }); // ensure the menu has a raycaster const raycaster = TypeStore.get("ObjectRaycaster"); if (raycaster) addNewComponent(this.menu, new raycaster()); return this.menu; } _poweredByNeedleElement; handleNeedleWatermark() { if (!this._poweredByNeedleElement) { this._poweredByNeedleElement = new ThreeMeshUI.Block({ width: "auto", height: "auto", fontSize: .05, whiteSpace: 'pre-wrap', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', margin: 0.02, borderRadius: .02, padding: .02, backgroundColor: 0xffffff, backgroundOpacity: 1, }); this._poweredByNeedleElement["needle:use_eventsystem"] = true; const onClick = new OnClick(this._context, () => globalThis.open("https://needle.tools", "_self")); addNewComponent(this._poweredByNeedleElement, onClick); const firstLabel = new ThreeMeshUI.Text({ textContent: "Powered by", width: "auto", height: "auto", }); const secondLabel = new ThreeMeshUI.Text({ textContent: "needle", width: "auto", height: "auto", fontSize: .07, margin: new Vector4(0, 0, 0, .02), }); this._poweredByNeedleElement.add(firstLabel); this._poweredByNeedleElement.add(secondLabel); this.menu?.add(this._poweredByNeedleElement); this.markDirty(); // const logoObject = needleLogoAsSVGObject(); // logoObject.position.y = 1; // this._context.scene.add(logoObject); const textureLoader = new TextureLoader(); textureLoader.load("./include/needle/poweredbyneedle.webp", (texture) => { onClick.allowModifyUI = false; firstLabel.removeFromParent(); secondLabel.removeFromParent(); const aspect = texture.image.width / texture.image.height; this._poweredByNeedleElement?.set({ backgroundImage: texture, backgroundOpacity: 1, width: .1 * aspect, height: .1 }); this.markDirty(); }); } if (this.menu) { const index = this.menu.children.indexOf(this._poweredByNeedleElement); if (!this._showNeedleLogo && hasProLicense()) { if (index >= 0) { this._poweredByNeedleElement.removeFromParent(); this.markDirty(); } } else { this._poweredByNeedleElement.visible = true; this.menu.add(this._poweredByNeedleElement); const newIndex = this.menu.children.indexOf(this._poweredByNeedleElement); if (index !== newIndex) { this.markDirty(); } } } } ensureFont() { let fontFamily = ThreeMeshUI.FontLibrary.getFontFamily(this.familyName); if (!fontFamily) { fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(this.familyName); const normal = fontFamily.addVariant("normal", "normal", "./include/needle/arial-msdf.json", "./include/needle/arial.png"); /** @ts-ignore */ normal?.addEventListener('ready', () => { this.markDirty(); }); } } createButton(menu, htmlButton) { const buttonParent = new ThreeMeshUI.Block({ width: "auto", height: "auto", whiteSpace: 'pre-wrap', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', backgroundColor: 0xffffff, backgroundOpacity: 0, padding: 0.02, margin: 0.01, borderRadius: 0.02, cursor: 'pointer', fontSize: 0.05, }); const text = new ThreeMeshUI.Text({ textContent: "", width: "auto", justifyContent: 'center', alignItems: 'center', backgroundOpacity: 0, backgroundColor: 0xffffff, fontFamily: this.familyName, color: 0x000000, borderRadius: 0.02, padding: .01, }); buttonParent.add(text); buttonParent["needle:use_eventsystem"] = true; const onClick = new OnClick(this._context, () => htmlButton.click()); addNewComponent(buttonParent, onClick); const spatialButton = new SpatialButton(this, menu, htmlButton, buttonParent, text); return spatialButton; } } class SpatialButton { menu; root; htmlbutton; spatialContainer; spatialText; spatialIcon; constructor(menu, root, htmlbutton, buttonContainer, buttonText) { this.menu = menu; this.root = root; this.htmlbutton = htmlbutton; this.spatialContainer = buttonContainer; this.spatialText = buttonText; const styleObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === "attributes") { if (mutation.attributeName === "style") { this.updateVisible(); } } else if (mutation.type === "childList") { this.updateText(); } } }); // watch attributes and content styleObserver.observe(htmlbutton, { attributes: true, childList: true }); this.updateText(); } add() { if (this.spatialContainer.parent != this.root) { this.root.add(this.spatialContainer); this.menu.markDirty(); this.updateVisible(); this.updateText(); } } remove() { if (this.spatialContainer.parent) { this.spatialContainer.removeFromParent(); this.menu.markDirty(); } } updateVisible() { const wasVisible = this.spatialContainer.visible; this.spatialContainer.visible = this.htmlbutton.style.display !== "none"; if (wasVisible !== this.spatialContainer.visible) { this.menu.markDirty(); } } _lastText = ""; updateText() { let newText = ""; let iconToCreate = ""; this.htmlbutton.childNodes.forEach((child) => { if (child.nodeType === Node.TEXT_NODE) { newText += child.textContent; } else if (child instanceof HTMLElement && isIconElement(child) && child.textContent) { iconToCreate = child.textContent; } }); if (this._lastText !== newText) { this._lastText = newText; this.spatialText.name = newText; this.spatialText.set({ textContent: newText }); this.menu.markDirty(); } if (newText.length <= 0) { if (this.spatialText.parent) { this.spatialText.removeFromParent(); this.menu.markDirty(); } } else { if (!this.spatialText.parent) { this.spatialContainer.add(this.spatialText); this.menu.markDirty(); } } if (iconToCreate) { this.createIcon(iconToCreate); } } _lastTexture; async createIcon(str) { if (!this.spatialIcon) { const texture = await getIconTexture(str); if (texture && !this.spatialIcon) { const size = 0.08; const icon = new ThreeMeshUI.Block({ width: size, height: size, backgroundColor: 0xffffff, backgroundImage: texture, backgroundOpacity: 1, margin: new Vector4(0, .005, 0, 0), }); this.spatialIcon = icon; this.spatialContainer.add(icon); this.menu.markDirty(); } } if (str != this._lastTexture) { this._lastTexture = str; const texture = await getIconTexture(str); if (texture) { this.spatialIcon?.set({ backgroundImage: texture }); this.menu.markDirty(); } } // make sure the icon is at the first index const index = this.spatialContainer.children.indexOf(this.spatialIcon); if (index > 0) { this.spatialContainer.children.splice(index, 1); this.spatialContainer.children.unshift(this.spatialIcon); this.menu.markDirty(); } } } // TODO: perhaps we should have a basic IComponent implementation in the engine folder to be able to write this more easily. OR possibly reduce the IComponent interface to the minimum class OnClick { isComponent = true; enabled = true; get activeAndEnabled() { return true; } __internalAwake() { } __internalEnable() { } __internalDisable() { } __internalStart() { } onEnable() { } onDisable() { } gameObject; allowModifyUI = true; get element() { return this.gameObject; } context; onclick; constructor(context, onclick) { this.context = context; this.onclick = onclick; } onPointerEnter() { this.context.input.setCursor("pointer"); if (this.allowModifyUI) { this.element.set({ backgroundOpacity: 1 }); ThreeMeshUI.update(); } } onPointerExit() { this.context.input.unsetCursor("pointer"); if (this.allowModifyUI) { this.element.set({ backgroundOpacity: 0 }); ThreeMeshUI.update(); } } onPointerDown(e) { e.use(); } onPointerUp(e) { e.use(); } onPointerClick(e) { e.use(); this.onclick(); } } //# sourceMappingURL=needle-menu-spatial.js.map