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