@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.
822 lines (814 loc) • 33.8 kB
JavaScript
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
import { PUBLIC_KEY, VERSION } from "../engine_constants.js";
import { registerLoader } from "../engine_gltf.js";
import { hasCommercialLicense } from "../engine_license.js";
import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "../engine_loaders.gltf.js";
import { NeedleLoader } from "../engine_loaders.js";
import { Context } from "../engine_setup.js";
import { nameToThreeTonemapping } from "../engine_tonemapping.js";
import { getParam } from "../engine_utils.js";
import { RGBAColor } from "../js-extensions/RGBAColor.js";
import { ensureFonts } from "./fonts.js";
import { arContainerClassName, AROverlayHandler } from "./needle-engine.ar-overlay.js";
import { calculateProgress01, EngineLoadingView } from "./needle-engine.loading.js";
// registering loader here too to make sure it's imported when using engine via vanilla js
registerLoader(NeedleLoader);
const debug = getParam("debugwebcomponent");
const htmlTagName = "needle-engine";
const vrContainerClassName = "vr";
const desktopContainerClassname = "desktop";
const knownClasses = [arContainerClassName, vrContainerClassName, desktopContainerClassname];
const arSessionActiveClassName = "ar-session-active";
const desktopSessionActiveClassName = "desktop-session-active";
const observedAttributes = [
"public-key",
"version",
"hash",
"src",
"camera-controls",
"loadstart",
"progress",
"loadfinished",
"dracoDecoderPath",
"dracoDecoderType",
"ktx2DecoderPath",
"tone-mapping",
"tone-mapping-exposure",
"background-blurriness",
"background-color",
];
// https://developers.google.com/web/fundamentals/web-components/customelements
/**
* <needle-engine> web component. See {@link NeedleEngineAttributes} attributes for supported attributes
* The needle engine web component creates and manages a needle engine context which is responsible for rendering a 3D scene using threejs.
* The needle engine context is created when the src attribute is set and disposed when the needle engine is removed from the document (you can prevent this by setting the keep-alive attribute to true).
* The needle engine context is accessible via the context property on the needle engine element (e.g. document.querySelector("needle-engine").context).
* @link https://engine.needle.tools/docs/reference/needle-engine-attributes
*
* @example
* <needle-engine src="https://example.com/scene.glb"></needle-engine>
* @example
* <needle-engine src="https://example.com/scene.glb" camera-controls="false"></needle-engine>
*/
export class NeedleEngineWebComponent extends HTMLElement {
static get observedAttributes() {
return observedAttributes;
}
get loadingProgress01() { return this._loadingProgress01; }
get loadingFinished() { return this.loadingProgress01 > .999; }
/**
* If set to false the camera controls are disabled. Default is true.
* @type {boolean | null}
* @memberof NeedleEngineAttributes
* @example
* <needle-engine camera-controls="false"></needle-engine>
* @example
* <needle-engine camera-controls="true"></needle-engine>
* @example
* <needle-engine camera-controls></needle-engine>
* @example
* <needle-engine></needle-engine>
* @returns {boolean | null} if the attribute is not set it returns null
*/
get cameraControls() {
const attr = this.getAttribute("camera-controls");
if (attr == null)
return null;
if (attr === null || attr === "False" || attr === "false" || attr === "0" || attr === "none")
return false;
return true;
}
/**
* Get the current context for this web component instance. The context is created when the src attribute is set and the loading has finished.
* The context is disposed when the needle engine is removed from the document (you can prevent this by setting the keep-alive attribute to true).
* @returns {Promise<Context>} a promise that resolves to the context when the loading has finished
*/
getContext() {
return new Promise((res, _rej) => {
if (this._context && this.loadingFinished) {
res(this._context);
}
else {
const cb = () => {
this.removeEventListener("loadfinished", cb);
if (this._context && this.loadingFinished) {
res(this._context);
}
};
this.addEventListener("loadfinished", cb);
}
});
}
/**
* Get the context that is created when the src attribute is set and the loading has finished.
*/
get context() { return this._context; }
_context;
_overlay_ar;
_loadingProgress01 = 0;
_loadingView;
_previousSrc = null;
/** set to true after <needle-engine> did load completely at least once. Set to false when <needle-engine> is removed from the document */
_didFullyLoad = false;
constructor() {
super();
this._overlay_ar = new AROverlayHandler();
// TODO: do we want to rename this event?
this.addEventListener("ready", this.onReady);
ensureFonts();
this.attachShadow({ mode: 'open' });
const template = document.createElement('template');
template.innerHTML = `<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap');
:host {
position: absolute;
display: block;
width: max(600px, 100%);
height: max(300px, 100%);
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
@media (max-width: 600px) {
:host {
width: 100%;
}
}
@media (max-height: 300px) {
:host {
height: 100%;
}
}
:host > div.canvas-wrapper {
width: 100%;
height: 100%;
}
:host canvas {
position: absolute;
user-select: none;
-webkit-user-select: none;
/** allow touch panning but no pinch zoom **/
/** but this doesnt work yet:
* touch-action: pan-x, pan-y;
**/
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-user-modify: none;
left: 0;
top: 0;
}
:host .content {
position: absolute;
top: 0;
width: 100%;
height: 100%;
visibility: visible;
z-index: 500; /* < must be less than the webxr buttons element */
pointer-events: none;
}
:host .overlay-content {
position: absolute;
user-select: auto;
pointer-events: all;
}
:host slot[name="quit-ar"]:hover {
cursor: pointer;
}
:host .quit-ar-button {
position: absolute;
// top: env(titlebar-area-y); /** this doesnt work **/
top: 60px; /** camera access needs a bit more space **/
right: 20px;
z-index: 9999;
}
</style>
<div class="canvas-wrapper"> <!-- this wrapper is necessary for WebXR https://github.com/meta-quest/immersive-web-emulator/issues/55 -->
<canvas></canvas>
</div>
<div class="content">
<slot class="overlay-content"></slot>
</div>
`;
if (this.shadowRoot)
this.shadowRoot.appendChild(template.content.cloneNode(true));
this._context = new Context({ domElement: this });
this.addEventListener("error", this.onError);
}
/**
* @internal
*/
async connectedCallback() {
if (debug) {
console.log("<needle-engine> connected");
}
this.setPublicKey();
this.setVersion();
this.addEventListener("xr-session-started", this.onXRSessionStarted);
this.onSetupDesktop();
if (!this.getAttribute("src")) {
const global = globalThis["needle:codegen_files"];
if (debug)
console.log("src is null, trying to load from globalThis[\"needle:codegen_files\"]", global);
if (global) {
if (debug)
console.log("globalThis[\"needle:codegen_files\"]", global);
this.setAttribute("src", global);
}
}
if (debug)
console.log("src", this.getAttribute("src"));
// we have to wait because codegen does set the src attribute when it's loaded
// which might happen after the element is connected
// if the `src` is then still null we want to initialize the default scene
const loadId = this._loadId;
setTimeout(() => {
if (this.isConnected === false)
return;
if (loadId !== this._loadId)
return;
this.onLoad();
}, 1);
}
/**
* @internal
*/
disconnectedCallback() {
this.removeEventListener("xr-session-started", this.onXRSessionStarted);
this._didFullyLoad = false;
const keepAlive = this.getAttribute("keep-alive");
const dispose = keepAlive == undefined || (keepAlive?.length > 0 && keepAlive !== "true" && keepAlive !== "1");
if (debug)
console.warn("<needle-engine> disconnected, keep-alive: \"" + keepAlive + "\"", typeof keepAlive, "Dispose=", dispose);
if (dispose) {
if (debug)
console.warn("<needle-engine> dispose");
this._context?.dispose();
this._context = null;
this._lastSourceFiles = null;
this._loadId += 1;
}
else {
if (debug)
console.warn("<needle-engine> is not disposed because keep-alive is set");
}
}
/**
* @internal
*/
attributeChangedCallback(name, oldValue, newValue) {
if (debug)
console.log("attributeChangedCallback", name, oldValue, newValue);
switch (name) {
case "src":
if (debug)
console.warn("<needle-engine src>\nchanged from \"", oldValue, "\" to \"", newValue, "\"");
this.onLoad();
// this._watcher?.onSourceChanged(newValue);
break;
case "hash":
if (this._context) {
this._context.hash = newValue;
}
break;
case "loadstart":
case "progress":
case "loadfinished":
if (typeof newValue === "string" && newValue.length > 0) {
if (debug)
console.log(name + " attribute changed", newValue);
this.registerEventFromAttribute(name, newValue);
}
break;
case "dracoDecoderPath":
if (debug)
console.log("dracoDecoderPath", newValue);
setDracoDecoderPath(newValue);
break;
case "dracoDecoderType":
if (newValue === "wasm" || newValue === "js") {
if (debug)
console.log("dracoDecoderType", newValue);
setDracoDecoderType(newValue);
}
else
console.error("Invalid dracoDecoderType", newValue, "expected js or wasm");
break;
case "ktx2DecoderPath":
if (debug)
console.log("ktx2DecoderPath", newValue);
setKtx2TranscoderPath(newValue);
break;
case "tone-mapping": {
this.applyAttributes();
break;
}
case "tone-mapping-exposure": {
this.applyAttributes();
break;
}
case "background-blurriness": {
const value = parseFloat(newValue);
if (value != undefined && this._context) {
this._context.scene.backgroundBlurriness = value;
}
break;
}
case "background-color": {
this.applyAttributes();
break;
}
case "public-key": {
if (newValue != PUBLIC_KEY)
this.setPublicKey();
break;
}
case "version": {
if (newValue != VERSION)
this.setVersion();
break;
}
}
}
/** The tonemapping setting configured as an attribute on the <needle-engine> component */
get toneMapping() {
const attribute = (this.getAttribute("tonemapping") || this.getAttribute("tone-mapping"));
return attribute;
}
_loadId = 0;
_abortController = null;
_lastSourceFiles = null;
_createContextPromise = null;
async onLoad() {
if (!this.isConnected)
return;
if (!this._context) {
if (debug)
console.warn("Create new context");
this._context = new Context({ domElement: this });
}
if (!this._context) {
console.error("Needle Engine: Context not initialized");
return;
}
const filesToLoad = this.getSourceFiles();
if (!this.checkIfSourceHasChanged(filesToLoad, this._lastSourceFiles)) {
return;
}
// Abort previous loading (if it's still running)
if (this._abortController) {
if (debug)
console.warn("Abort previous loading process");
this._abortController.abort();
this._abortController = null;
}
this._lastSourceFiles = filesToLoad;
const loadId = ++this._loadId;
if (filesToLoad === null || filesToLoad === undefined || filesToLoad.length <= 0) {
if (debug)
console.warn("Clear scene", filesToLoad);
this._context.clear();
if (loadId !== this._loadId)
return;
}
const alias = this.getAttribute("alias");
this.classList.add("loading");
// Loading start events
const allowOverridingDefaultLoading = hasCommercialLicense();
// default loading can be overriden by calling preventDefault in the onload start event
this.ensureLoadStartIsRegistered();
let useDefaultLoading = this.dispatchEvent(new CustomEvent("loadstart", {
detail: {
context: this._context,
alias: alias
},
cancelable: true
}));
if (allowOverridingDefaultLoading) {
// Handle the <needle-engine hide-loading-overlay> attribute
const hideOverlay = this.getAttribute("hide-loading-overlay");
if (hideOverlay !== null && hideOverlay !== undefined && hideOverlay !== "0") {
useDefaultLoading = false;
}
}
// for local development we allow overriding the loading screen - but we notify the user that it won't work in a deployed environment
if (useDefaultLoading === false && !allowOverridingDefaultLoading) {
if (!isDevEnvironment())
useDefaultLoading = true;
console.warn("Needle Engine: You need a commercial license to override the default loading view. Visit https://needle.tools/pricing");
if (isDevEnvironment())
showBalloonWarning("You need a <a target=\"_blank\" href=\"https://needle.tools/pricing\">commercial license</a> to override the default loading view. This will not work in production.");
}
// create the loading view idf necessary
if (!this._loadingView && useDefaultLoading)
this._loadingView = new EngineLoadingView(this);
if (useDefaultLoading) {
// Only show the loading screen immedialty if we haven't loaded anything before
if (this._didFullyLoad !== true)
this._loadingView?.onLoadingBegin("begin load");
else {
// If we have loaded a glb previously and are loading a new glb due to e.g. src change
// we don't want to show the loading screen immediately to avoid blinking if the glb to be loaded is tiny
// so we wait a bit and only show the loading screen if the loading takes longer than a short moment
setTimeout(() => {
// If the loading progress is already above ~ 70% we also don't need to show the loading screen anymore
if (this._loadingView && this._loadingProgress01 < 0.3 && this._loadId === loadId)
this._loadingView.onLoadingBegin("begin load");
}, 300);
}
}
if (debug)
console.warn("--------------\nNeedle Engine: Begin loading " + loadId + "\n", filesToLoad);
this.onBeforeBeginLoading();
const loadedFiles = [];
const progressEventDetail = {
context: this._context,
name: "",
progress: {},
index: 0,
count: filesToLoad.length,
totalProgress01: this._loadingProgress01
};
const progressEvent = new CustomEvent("progress", { detail: progressEventDetail });
const displayNames = new Array();
const controller = new AbortController();
this._abortController = controller;
const args = {
files: filesToLoad,
abortSignal: controller.signal,
onLoadingProgress: evt => {
if (debug)
console.debug("Loading progress: ", evt);
if (controller.signal.aborted)
return;
const index = evt.index;
if (!displayNames[index] && evt.name) {
displayNames[index] = getDisplayName(evt.name);
}
evt.name = displayNames[index];
if (useDefaultLoading)
this._loadingView?.onLoadingUpdate(evt);
progressEventDetail.name = evt.name;
progressEventDetail.progress = evt.progress;
this._loadingProgress01 = calculateProgress01(evt);
progressEventDetail.totalProgress01 = this._loadingProgress01;
this.dispatchEvent(progressEvent);
},
onLoadingFinished: (_index, file, glTF) => {
if (debug)
console.debug(`Finished loading \"${file}\" (aborted? ${controller.signal.aborted})`);
if (controller.signal.aborted)
return;
if (glTF) {
loadedFiles.push({
src: file,
file: glTF
});
}
}
};
const currentHash = this.getAttribute("hash");
if (currentHash !== null && currentHash !== undefined)
this._context.hash = currentHash;
this._context.alias = alias;
this._createContextPromise = this._context.create(args);
const success = await this._createContextPromise;
this.applyAttributes();
if (debug)
console.warn("--------------\nNeedle Engine: finished loading " + loadId + "\n", filesToLoad, `Aborted? ${controller.signal.aborted}`);
if (controller.signal.aborted) {
console.log("Loading finished but aborted...");
return;
}
if (this._loadId !== loadId) {
console.log("Load id changed during loading process");
return;
}
this._loadingProgress01 = 1;
if (useDefaultLoading && success) {
this._loadingView?.onLoadingUpdate(1, "creating scene");
}
this._didFullyLoad = true;
this.classList.remove("loading");
this.classList.add("loading-finished");
this.dispatchEvent(new CustomEvent("loadfinished", {
detail: {
context: this._context,
src: alias,
loadedFiles: loadedFiles,
}
}));
}
applyAttributes() {
// set tonemapping if configured
if (this._context?.renderer) {
const threeTonemapping = nameToThreeTonemapping(this.toneMapping);
if (threeTonemapping !== undefined) {
this._context.renderer.toneMapping = threeTonemapping;
}
const exposure = this.getAttribute("tone-mapping-exposure");
if (exposure !== null && exposure !== undefined) {
const value = parseFloat(exposure);
if (!isNaN(value))
this._context.renderer.toneMappingExposure = value;
}
}
const backgroundBlurriness = this.getAttribute("background-blurriness");
if (backgroundBlurriness !== null && backgroundBlurriness !== undefined) {
const value = parseFloat(backgroundBlurriness);
if (value !== undefined && this._context) {
this._context.scene.backgroundBlurriness = value;
}
}
const backgroundColor = this.getAttribute("background-color");
if (this._context?.renderer) {
if (typeof backgroundColor === "string" && backgroundColor.length > 0) {
const rgbaColor = RGBAColor.fromColorRepresentation(backgroundColor);
if (debug)
console.debug("<needle-engine> background-color changed, str:", backgroundColor, "→", rgbaColor);
this._context.renderer.setClearColor(rgbaColor, rgbaColor.alpha);
this.context.scene.background = null;
}
}
}
onXRSessionStarted = () => {
const xrSessionMode = this.context.xrSessionMode;
if (xrSessionMode === "immersive-ar")
this.onEnterAR(this.context.xrSession);
else if (xrSessionMode === "immersive-vr")
this.onEnterVR(this.context.xrSession);
// handle session end:
this.context.xrSession?.addEventListener("end", () => {
this.dispatchEvent(new CustomEvent("xr-session-ended", { detail: { session: this.context.xrSession, context: this._context, sessionMode: xrSessionMode } }));
if (xrSessionMode === "immersive-ar")
this.onExitAR(this.context.xrSession);
else if (xrSessionMode === "immersive-vr")
this.onExitVR(this.context.xrSession);
});
};
/** called by the context when the first frame has been rendered */
onReady = () => this._loadingView?.onLoadingFinished();
onError = () => this._loadingView?.setMessage("Loading failed!");
internalSetLoadingMessage(str) {
this._loadingView?.setMessage(str);
}
getSourceFiles() {
const src = this.getAttribute("src");
if (!src)
return [];
let filesToLoad;
// When using globalThis the src is an array already
if (Array.isArray(src)) {
filesToLoad = src;
}
// When assigned from codegen the src is a stringified array
else if (src.startsWith("[") && src.endsWith("]")) {
filesToLoad = JSON.parse(src);
}
// src.toString for an array produces a comma separated list
else if (src.includes(",")) {
filesToLoad = src.split(",");
}
else
filesToLoad = [src];
// filter out invalid or empty strings
for (let i = filesToLoad.length - 1; i >= 0; i--) {
const file = filesToLoad[i];
if (file === "null" || file === "undefined" || file?.length <= 0)
filesToLoad.splice(i, 1);
}
return filesToLoad;
}
checkIfSourceHasChanged(current, previous) {
if (current?.length !== previous?.length)
return true;
if (current == null && previous !== null)
return true;
if (current !== null && previous == null)
return true;
if (current !== null && previous !== null) {
for (let i = 0; i < current?.length; i++) {
if (current[i] !== previous[i])
return true;
}
}
return false;
}
_previouslyRegisteredMap = new Map();
ensureLoadStartIsRegistered() {
const attributeValue = this.getAttribute("loadstart");
if (attributeValue)
this.registerEventFromAttribute("loadstart", attributeValue);
}
registerEventFromAttribute(eventName, code) {
const prev = this._previouslyRegisteredMap.get(eventName);
if (prev) {
this._previouslyRegisteredMap.delete(eventName);
this.removeEventListener(eventName, prev);
}
if (typeof code === "string" && code.length > 0) {
try {
// indirect eval https://esbuild.github.io/content-types/#direct-eval
const fn = (0, eval)(code);
// const fn = new Function(newValue);
if (typeof fn === "function") {
this._previouslyRegisteredMap.set(eventName, fn);
this.addEventListener(eventName, evt => fn?.call(globalThis, this._context, evt));
}
}
catch (err) {
console.error("Error registering event " + eventName + "=\"" + code + "\" failed with the following error:\n", err);
}
}
}
setPublicKey() {
if (PUBLIC_KEY && PUBLIC_KEY.length > 0)
this.setAttribute("public-key", PUBLIC_KEY);
}
setVersion() {
if (VERSION && VERSION.length > 0) {
this.setAttribute("version", VERSION);
}
}
/**
* @internal
*/
getAROverlayContainer() {
return this._overlay_ar.createOverlayContainer(this);
}
/**
* @internal
*/
getVROverlayContainer() {
for (let i = 0; i < this.children.length; i++) {
const ch = this.children[i];
if (ch.classList.contains("vr"))
return ch;
}
return null;
}
/**
* @internal
*/
onEnterAR(session) {
this.onSetupAR();
const overlayContainer = this.getAROverlayContainer();
this._overlay_ar.onBegin(this._context, overlayContainer, session);
this.dispatchEvent(new CustomEvent("enter-ar", { detail: { session: session, context: this._context, htmlContainer: this._overlay_ar?.ARContainer } }));
}
/**
* @internal
*/
onExitAR(session) {
this._overlay_ar.onEnd(this._context);
this.onSetupDesktop();
this.dispatchEvent(new CustomEvent("exit-ar", { detail: { session: session, context: this._context, htmlContainer: this._overlay_ar?.ARContainer } }));
}
/**
* @internal
*/
onEnterVR(session) {
this.onSetupVR();
this.dispatchEvent(new CustomEvent("enter-vr", { detail: { session: session, context: this._context } }));
}
/**
* @internal
*/
onExitVR(session) {
this.onSetupDesktop();
this.dispatchEvent(new CustomEvent("exit-vr", { detail: { session: session, context: this._context } }));
}
onSetupAR() {
this.classList.add(arSessionActiveClassName);
this.classList.remove(desktopSessionActiveClassName);
const arContainer = this.getAROverlayContainer();
if (debug)
console.warn("onSetupAR:", arContainer);
if (arContainer) {
arContainer.classList.add(arSessionActiveClassName);
arContainer.classList.remove(desktopSessionActiveClassName);
}
this.foreachHtmlElement(ch => this.setupElementsForMode(ch, arContainerClassName));
}
onSetupVR() {
this.classList.remove(arSessionActiveClassName);
this.classList.remove(desktopSessionActiveClassName);
this.foreachHtmlElement(ch => this.setupElementsForMode(ch, vrContainerClassName));
}
onSetupDesktop() {
this.classList.remove(arSessionActiveClassName);
this.classList.add(desktopSessionActiveClassName);
const arContainer = this.getAROverlayContainer();
if (arContainer) {
arContainer.classList.remove(arSessionActiveClassName);
arContainer.classList.add(desktopSessionActiveClassName);
}
this.foreachHtmlElement(ch => this.setupElementsForMode(ch, desktopContainerClassname));
}
setupElementsForMode(el, currentSessionType, _session = null) {
if (el === this._context?.renderer?.domElement)
return;
if (el.id === "VRButton" || el.id === "ARButton")
return;
const classList = el.classList;
if (classList.contains(currentSessionType)) {
el.style.visibility = "visible";
if (el.style.display === "none")
el.style.display = "block";
}
else {
// only modify style for elements that have a known class (e.g. marked as vr ar desktop)
for (const known of knownClasses) {
if (el.classList.contains(known)) {
el.style.visibility = "hidden";
el.style.display = "none";
}
}
}
}
foreachHtmlElement(cb) {
for (let i = 0; i < this.children.length; i++) {
const ch = this.children[i];
if (ch.style)
cb(ch);
}
}
onBeforeBeginLoading() {
const customDracoDecoderPath = this.getAttribute("dracoDecoderPath");
if (customDracoDecoderPath) {
if (debug)
console.log("using custom draco decoder path", customDracoDecoderPath);
setDracoDecoderPath(customDracoDecoderPath);
}
const customDracoDecoderType = this.getAttribute("dracoDecoderType");
if (customDracoDecoderType) {
if (debug)
console.log("using custom draco decoder type", customDracoDecoderType);
setDracoDecoderType(customDracoDecoderType);
}
const customKtx2DecoderPath = this.getAttribute("ktx2DecoderPath");
if (customKtx2DecoderPath) {
if (debug)
console.log("using custom ktx2 decoder path", customKtx2DecoderPath);
setKtx2TranscoderPath(customKtx2DecoderPath);
}
}
}
if (typeof window !== "undefined" && !window.customElements.get(htmlTagName))
window.customElements.define(htmlTagName, NeedleEngineWebComponent);
function getDisplayName(str) {
if (str.startsWith("blob:")) {
return "blob";
}
const parts = str.split("/");
let name = parts[parts.length - 1];
// Remove params
const paramsIndex = name.indexOf("?");
if (paramsIndex > 0)
name = name.substring(0, paramsIndex);
const equalSign = name.indexOf("=");
if (equalSign > 0)
name = name.substring(equalSign);
const extension = name.split(".").pop();
const extensions = ["glb", "gltf", "usdz", "usd", "fbx", "obj", "mtl"];
const matchedIndex = !extension ? -1 : extensions.indexOf(extension.toLowerCase());
if (extension && matchedIndex >= 0) {
name = name.substring(0, name.length - extension.length - 1);
}
name = decodeURIComponent(name);
if (name.length > 3) {
let displayName = "";
let lastCharacterWasSpace = false;
const ignoredCharacters = ["(", ")", "[", "]", "{", "}", ":", ";", ",", ".", "!", "?"];
for (let i = 0; i < name.length; i++) {
let c = name[i];
if (c === "_" || c === "-")
c = " ";
if (c === ' ' && displayName.length <= 0)
continue;
const isIgnored = ignoredCharacters.includes(c);
if (isIgnored)
continue;
const isFirstCharacter = displayName.length === 0;
if (isFirstCharacter) {
c = c.toUpperCase();
}
if (lastCharacterWasSpace && c === " ") {
continue;
}
if (lastCharacterWasSpace) {
c = c.toUpperCase();
}
lastCharacterWasSpace = false;
displayName += c;
if (c === " ") {
lastCharacterWasSpace = true;
}
}
if (isDevEnvironment() && name !== displayName)
console.debug("Generated display name: \"" + name + "\" → \"" + displayName + "\"");
return displayName.trim();
}
if (isDevEnvironment())
console.debug("Loading: use default name", name);
return name;
}
//# sourceMappingURL=needle-engine.js.map