@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.
404 lines (354 loc) • 13.4 kB
text/typescript
import { ContextRegistry } from "../engine_context_registry.js";
import { isLocalNetwork } from "../engine_networking_utils.js";
import { DeviceUtilities, getParam } from "../engine_utils.js";
const debug = getParam("debugdebug");
let hide = false;
if (getParam("noerrors") || getParam("nooverlaymessages")) hide = true;
const globalErrorContainerKey = "needle_engine_global_error_container";
export enum LogType {
Log,
Warn,
Error
}
export type BalloonOptions = {
type: LogType,
/**
* A key can be used to update an existing message instead of showing a new one.
*/
key?: string,
/**
* If true, only show this message once. If the same message is logged again, it will be ignored. Note that messages are considered the same if their text is identical, so using this with dynamic values might lead to unexpected results.
*/
once?: boolean,
/**
* Duration in seconds for how long the message should be shown. By default, messages will be shown for 10 seconds.
*/
duration?: number,
}
export function getErrorCount() {
return _errorCount;
}
const _errorListeners = new Array<(...args: any[]) => void>();
/** Register callback when a new error happens */
export function onError(cb: (...args: any[]) => void) { _errorListeners.push(cb); }
/** Unregister error callback */
export function offError(cb: (...args: any[]) => void) { _errorListeners.splice(_errorListeners.indexOf(cb), 1); }
let isInvokingErrorListeners = false;
function invokeErrorListeners(...args: any[]) {
if (isInvokingErrorListeners) return; // prevent infinite loop
isInvokingErrorListeners = true;
try {
for (let i = 0; i < _errorListeners.length; i++) {
_errorListeners[i](...args);
}
}
catch (e) {
console.error(e);
}
isInvokingErrorListeners = false;
}
const originalConsoleError = console.error;
const patchedConsoleError = function (...args: any[]) {
originalConsoleError.apply(console, args);
onParseError(args);
addLog(LogType.Error, args, {}, null, null);
onReceivedError(...args);
}
/** Set false to prevent overlay messages from being shown */
export function setAllowBalloonMessages(allow: boolean) {
hide = !allow;
if (allow) console.error = patchedConsoleError;
else console.error = originalConsoleError;
}
/**
* @deprecated Use {@link setAllowBalloonMessages} instead
*/
export function setAllowOverlayMessages(allow: boolean) {
return setAllowBalloonMessages(allow);
}
export function makeErrorsVisibleForDevelopment() {
if (hide) return;
if (debug)
console.warn("Patch console", window.location.hostname);
console.error = patchedConsoleError;
window.addEventListener("error", (event) => {
if (!event) return;
const message = event.error;
if (message === undefined) {
if (isLocalNetwork())
console.warn("Received unknown error", event, event.target);
return;
}
addLog(LogType.Error, message, {}, event.filename, event.lineno);
onReceivedError(event);
}, true);
window.addEventListener("unhandledrejection", (event) => {
if (hide) return;
if (!event) return;
if (event.reason)
addLog(LogType.Error, event.reason.message, {}, event.reason.stack);
else
addLog(LogType.Error, "unhandled rejection");
onReceivedError(event);
});
}
let _errorCount = 0;
function onReceivedError(...args: any[]) {
_errorCount += 1;
invokeErrorListeners(...args);
}
function onParseError(args: Array<any>) {
if (Array.isArray(args)) {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (typeof arg === "string" && arg.startsWith("THREE.PropertyBinding: Trying to update node for track:")) {
args[i] = "Some animated objects couldn't be found: see console for details";
}
}
}
}
const seen = new Set<string>();
export function addLog(type: LogType, message: string | any[], opts: Omit<BalloonOptions, "type"> = {}, _file?: string | null, _line?: number | null) {
if (hide) return;
if (opts.once === true) {
let key = "";
if (Array.isArray(message)) {
for (let i = 0; i < message.length; i++) {
let msgPart = message[i];
if (msgPart instanceof Error) {
msgPart = msgPart.message;
}
if (typeof msgPart === "object") continue;
if (i > 0) key += " ";
key += msgPart;
}
} else if (typeof message === "string") {
key = message;
}
if (seen.has(key)) return;
seen.add(key);
}
const context = ContextRegistry.Current;
let domElement = context?.domElement ?? document.querySelector("needle-engine");
// check if we're in webxr dom overlay
if (context?.isInAR) {
domElement = context.arOverlayElement;
}
if (!domElement) return;
if (Array.isArray(message)) {
let newMessage = "";
for (let i = 0; i < message.length; i++) {
let msg = message[i];
if (msg instanceof Error) {
msg = msg.message;
}
if (typeof msg === "object") continue;
if (i > 0) newMessage += " ";
newMessage += msg;
}
message = newMessage;
}
if (!message || message.length <= 0) return;
showMessage(type, domElement, message, opts);
}
// function getLocation(err: Error): string {
// const location = err.stack;
// console.log(location);
// if (location) {
// locationRegex.exec(location);
// const match = locationRegex.exec(location);
// return match ? match[1] : "";
// }
// return "";
// }
/**
*
*/
const currentMessages = new Map<string, {
update: (newText: string, opts: Omit<BalloonOptions, "type">) => void,
removeFunction: () => void,
}>();
const minDuration = .2; // minimum duration in seconds for messages to be shown, to prevent flicker
function showMessage(type: LogType, element: HTMLElement, msg: string | null | undefined, opts: Omit<BalloonOptions, "type"> = {}) {
if (msg === null || msg === undefined) return;
const container = getLogsContainer(element);
if (container.childElementCount >= 20) {
const last = container.lastElementChild;
returnMessageContainer(last as HTMLElement);
}
// truncate long messages before they go into the cache/set
if (msg.length > 400) msg = msg.substring(0, 400) + "...";
const key = opts.key ?? msg;
if (currentMessages.has(key)) {
const existing = currentMessages.get(key);
existing?.update(msg, opts);
return;
}
const msgcontainer = getMessageContainer(type, msg);
container.prepend(msgcontainer);
const removeFunction = () => {
currentMessages.delete(key);
returnMessageContainer(msgcontainer);
};
let timeout = setTimeout(removeFunction, (Math.max(minDuration, opts.duration ?? 10)) * 1000);
currentMessages.set(key, {
update: (newText, newOpts) => {
if (newText.length > 400) newText = newText.substring(0, 400) + "...";
msgcontainer.innerHTML = newText;
if (newOpts.duration) {
clearTimeout(timeout);
timeout = setTimeout(removeFunction, Math.max(minDuration, newOpts.duration) * 1000);
}
},
removeFunction
});
}
/**
* Clear all overlay messages from the screen
*/
export function clearMessages() {
if (debug) console.log("Clearing messages");
for (const cur of currentMessages.values()) {
cur?.removeFunction.call(cur);
}
currentMessages.clear();
}
const logsContainerStyles = `
@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap');
div[data-needle_engine_debug_overlay] {
font-family: 'Roboto Flex', sans-serif;
font-weight: 400;
font-size: 16px;
}
div[data-needle_engine_debug_overlay] strong {
font-weight: 700;
}
div[data-needle_engine_debug_overlay] a {
color: white;
text-decoration: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
div[data-needle_engine_debug_overlay] a:hover {
text-decoration: none;
border: none;
}
div[data-needle_engine_debug_overlay] .log strong {
color: rgba(200,200,200,.9);
}
div[data-needle_engine_debug_overlay] .warn strong {
color: rgba(255,255,230, 1);
}
div[data-needle_engine_debug_overlay] .error strong {
color: rgba(255,100,120, 1);
}
`;
function getLogsContainer(domElement: HTMLElement): HTMLElement {
if (!globalThis[globalErrorContainerKey]) {
globalThis[globalErrorContainerKey] = new Map<HTMLElement, HTMLElement>();
}
const errorsMap = globalThis[globalErrorContainerKey] as Map<HTMLElement, HTMLElement>;
if (errorsMap.has(domElement)) {
return errorsMap.get(domElement)!;
} else {
const container = document.createElement("div");
errorsMap.set(domElement, container);
container.setAttribute("data-needle_engine_debug_overlay", "");
container.classList.add("debug-container");
container.style.cssText = `
position: absolute;
top: 0;
right: 5px;
padding-top: env(safe-area-inset-top, 0px);
max-width: 70%;
max-height: calc(100% - 105px);
z-index: 100000;
pointer-events: scroll;
display: flex;
align-items: end;
flex-direction: column;
color: white;
overflow: auto;
word-break: break-word;
`
// Show the messages left aligned in app clip (because there's a menu button on the right side)
if (DeviceUtilities.isNeedleAppClip()) {
container.style.left = "5px";
container.style.right = "unset";
}
// for safe area insets to work we need to set
// <meta name="viewport" content="viewport-fit=cover">
const meta = document.querySelector('meta[name="viewport"]');
if (meta && !meta.getAttribute("content")?.includes("viewport-fit=")) {
meta.setAttribute("content", meta.getAttribute("content") + ",viewport-fit=cover");
}
if (domElement.shadowRoot)
domElement.shadowRoot.appendChild(container);
else domElement.appendChild(container);
const style = document.createElement("style");
style.innerHTML = logsContainerStyles;
container.appendChild(style);
return container;
}
}
const typeSymbol = Symbol("logtype");
const containerCache = new Map<LogType, HTMLElement[]>();
function returnMessageContainer(container: HTMLElement) {
container.remove();
const type = container[typeSymbol];
const containers = containerCache.get(type) ?? [];
containers.push(container);
containerCache.set(type, containers);
}
function getMessageContainer(type: LogType, msg: string): HTMLElement {
if (containerCache.has(type)) {
const containers = containerCache.get(type)!;
if (containers.length > 0) {
const container = containers.pop()!;
container.innerHTML = msg;
return container;
}
}
const element = document.createElement("div");
element.setAttribute("data-id", "__needle_engine_debug_overlay");
element.style.marginRight = "5px";
element.style.padding = ".5em";
element.style.backgroundColor = "rgba(0,0,0,.9)";
element.style.marginTop = "5px";
element.style.marginBottom = "3px";
element.style.borderRadius = "8px";
element.style.pointerEvents = "all";
element.style.userSelect = "text";
element.style.maxWidth = "250px";
element.style.whiteSpace = "pre-wrap";
element.style["backdrop-filter"] = "blur(10px)";
element.style["-webkit-backdrop-filter"] = "blur(10px)";
element.style.backgroundColor = "rgba(20,20,20,.8)";
element.style.boxShadow = "inset 0 0 80px rgba(0,0,0,.2), 0 0 5px rgba(0,0,0,.2)";
element.style.border = "1px solid rgba(160,160,160,.2)";
element[typeSymbol] = type;
switch (type) {
case LogType.Log:
element.classList.add("log");
element.style.color = "rgba(200,200,200,.7)";
element.style.backgroundColor = "rgba(40,40,40,.7)";
// element.style.backgroundColor = "rgba(200,200,200,.5)";
break;
case LogType.Warn:
element.classList.add("warn");
element.style.color = "rgb(255, 255, 150)";
element.style.backgroundColor = "rgba(50,50,20,.8)";
// element.style.backgroundColor = "rgba(245,245,50,.5)";
break;
case LogType.Error:
element.classList.add("error");
element.style.color = "rgb(255, 50, 50";
element.style.backgroundColor = "rgba(50,20,20,.8)";
// element.style.backgroundColor = "rgba(255,50,50,.5)";
break;
}
element.title = "Open the browser console (F12) for more information";
// msg = msg.replace(/[\n\r]/g, "<br/>");
// console.log(msg);
element.innerHTML = msg;
return element;
}