@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.
540 lines • 22.5 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Color } from 'three';
import * as ThreeMeshUI from 'three-mesh-ui';
import { serializable } from '../../engine/engine_serialization_decorator.js';
import { getParam } from '../../engine/engine_utils.js';
import { Graphic } from './Graphic.js';
import { updateRenderSettings } from './Utils.js';
const debug = getParam("debugtext");
export var TextAnchor;
(function (TextAnchor) {
TextAnchor[TextAnchor["UpperLeft"] = 0] = "UpperLeft";
TextAnchor[TextAnchor["UpperCenter"] = 1] = "UpperCenter";
TextAnchor[TextAnchor["UpperRight"] = 2] = "UpperRight";
TextAnchor[TextAnchor["MiddleLeft"] = 3] = "MiddleLeft";
TextAnchor[TextAnchor["MiddleCenter"] = 4] = "MiddleCenter";
TextAnchor[TextAnchor["MiddleRight"] = 5] = "MiddleRight";
TextAnchor[TextAnchor["LowerLeft"] = 6] = "LowerLeft";
TextAnchor[TextAnchor["LowerCenter"] = 7] = "LowerCenter";
TextAnchor[TextAnchor["LowerRight"] = 8] = "LowerRight";
})(TextAnchor || (TextAnchor = {}));
export var VerticalWrapMode;
(function (VerticalWrapMode) {
VerticalWrapMode[VerticalWrapMode["Truncate"] = 0] = "Truncate";
VerticalWrapMode[VerticalWrapMode["Overflow"] = 1] = "Overflow";
})(VerticalWrapMode || (VerticalWrapMode = {}));
var HorizontalWrapMode;
(function (HorizontalWrapMode) {
HorizontalWrapMode[HorizontalWrapMode["Wrap"] = 0] = "Wrap";
HorizontalWrapMode[HorizontalWrapMode["Overflow"] = 1] = "Overflow";
})(HorizontalWrapMode || (HorizontalWrapMode = {}));
export var FontStyle;
(function (FontStyle) {
FontStyle[FontStyle["Normal"] = 0] = "Normal";
FontStyle[FontStyle["Bold"] = 1] = "Bold";
FontStyle[FontStyle["Italic"] = 2] = "Italic";
FontStyle[FontStyle["BoldAndItalic"] = 3] = "BoldAndItalic";
})(FontStyle || (FontStyle = {}));
/**
* @category User Interface
* @group Components
*/
export class Text extends Graphic {
alignment = TextAnchor.UpperLeft;
verticalOverflow = VerticalWrapMode.Truncate;
horizontalOverflow = HorizontalWrapMode.Wrap;
lineSpacing = 1;
supportRichText = false;
font;
fontStyle = FontStyle.Normal;
// private _alphaFactor : number = 1;
setAlphaFactor(factor) {
super.setAlphaFactor(factor);
this.uiObject?.set({ fontOpacity: this.color.alpha * this.alphaFactor });
this.markDirty();
}
get text() {
return this._text;
}
set text(val) {
if (val !== this._text) {
this._text = val;
this.feedText(this.text, this.supportRichText);
this.markDirty();
}
}
set_text(val) {
this.text = val;
}
get fontSize() {
return this._fontSize;
}
set fontSize(val) {
// Setting that kind of property in a parent, would cascade to each 'non-overrided' children.
this._fontSize = val;
this.uiObject?.set({ fontSize: val });
}
sRGBTextColor = new Color(1, 0, 1);
onColorChanged() {
this.sRGBTextColor.copy(this.color);
this.sRGBTextColor.convertLinearToSRGB();
this.uiObject?.set({ color: this.sRGBTextColor, fontOpacity: this.color.alpha });
}
onParentRectTransformChanged() {
super.onParentRectTransformChanged();
if (this.uiObject) {
this.updateOverflow();
}
}
// onBeforeRender(): void {
// // TODO TMUI @swingingtom this is so we don't have text clipping
// if (this.uiObject && (this.Canvas?.screenspace || this.context.isInVR)) {
// this.updateOverflow();
// }
// }
onBeforeCanvasRender(_canvas) {
// ensure the text clipping matrix is updated (this was a problem with multiple screenspace canvases due to canvas reparenting)
this.updateOverflow();
}
updateOverflow() {
// HACK: force the text overflow to update
const overflow = this.uiObject?._overflow;
if (overflow) {
overflow._needsUpdate = true;
// the screenspace canvas does force an update, no need to mark dirty here
}
}
onCreate(_opts) {
if (debug)
console.log(this);
if (this.horizontalOverflow == HorizontalWrapMode.Overflow) {
// Only line characters in the textContent (\n,\r\t) would be able to multiline the text
_opts.whiteSpace = 'pre';
}
if (this.verticalOverflow == VerticalWrapMode.Truncate) {
this.context.renderer.localClippingEnabled = true;
_opts.overflow = 'hidden';
}
// @marwie : this combination is currently KO. See sample "Overflow Overview"
if (this.horizontalOverflow == HorizontalWrapMode.Overflow && this.verticalOverflow == VerticalWrapMode.Truncate) {
// This could fix this combination, but would require anchors updates to replace element
// _opts.width = 'auto';
}
_opts.lineHeight = this.lineSpacing;
// @marwie : Should be fixed. Currently _opts are always fed with :
// backgroundOpacity : color.opacity
// backgroundColor : color
delete _opts.backgroundOpacity;
delete _opts.backgroundColor;
// helper to show bounds of text element
if (debug) {
_opts.backgroundColor = 0xff9900;
_opts.backgroundOpacity = 0.5;
}
const rt = this.rectTransform;
// Texts now support both options, block and inline, and inline has all default to inherit
_opts = { ..._opts, ...this.getTextOpts() };
this.getAlignment(_opts);
if (debug) {
_opts.backgroundColor = Math.random() * 0xffffff;
_opts.backgroundOpacity = 0.1;
}
this.uiObject = rt.createNewText(_opts);
this.feedText(this.text, this.supportRichText);
}
onAfterAddedToScene() {
super.onAfterAddedToScene();
this.handleTextRenderOnTop();
}
_text = "";
_fontSize = 12;
_textMeshUi = null;
getTextOpts() {
const fontSize = this.fontSize;
// if (this.canvas) {
// fontSize /= this.canvas?.scaleFactor;
// }
const textOpts = {
color: this.color,
fontOpacity: this.color.alpha,
fontSize: fontSize,
fontKerning: "normal",
};
this.setFont(textOpts, this.fontStyle);
return textOpts;
}
onEnable() {
super.onEnable();
this._didHandleTextRenderOnTop = false;
if (this.uiObject) {
// @ts-ignore
// @TODO : Evaluate the need of keeping it anonymous.
// From v7.x afterUpdate can be removed but requires a reference
this.uiObject.addAfterUpdate(() => {
// We need to update the shadow owner when the text updates
// because once the font has loaded we get new children (a new mesh)
// which is the text, it needs to be linked back to this component
// to be properly handled by the EventSystem
// since the EventSystem looks for shadow component owners to handle events
this.setShadowComponentOwner(this.uiObject);
this.markDirty();
});
}
setTimeout(() => this.markDirty(), 10);
this.canvas?.registerEventReceiver(this);
}
onDisable() {
super.onDisable();
this.canvas?.unregisterEventReceiver(this);
}
getAlignment(opts) {
opts.flexDirection = "column";
switch (this.alignment) {
case TextAnchor.UpperLeft:
case TextAnchor.MiddleLeft:
case TextAnchor.LowerLeft:
opts.textAlign = "left";
break;
case TextAnchor.UpperCenter:
case TextAnchor.MiddleCenter:
case TextAnchor.LowerCenter:
opts.textAlign = "center";
break;
case TextAnchor.UpperRight:
case TextAnchor.MiddleRight:
case TextAnchor.LowerRight:
opts.textAlign = "right";
break;
}
switch (this.alignment) {
default:
case TextAnchor.UpperLeft:
case TextAnchor.UpperCenter:
case TextAnchor.UpperRight:
opts.alignItems = "start";
break;
case TextAnchor.MiddleLeft:
case TextAnchor.MiddleCenter:
case TextAnchor.MiddleRight:
opts.alignItems = "center";
break;
case TextAnchor.LowerLeft:
case TextAnchor.LowerCenter:
case TextAnchor.LowerRight:
opts.alignItems = "end";
break;
}
return opts;
}
feedText(text, richText) {
// if (!text || text.length <= 0) return;
// if (!text ) return;
if (debug)
console.log("feedText", this.uiObject, text, richText);
if (!this.uiObject)
return;
if (!this._textMeshUi)
this._textMeshUi = [];
// this doesnt work and produces errors when length is 0:
// this.uiObject.textContent = " ";
// reset the current text (e.g. when switching from "Hello" to "Hello <b>World</b>")
// @TODO swingingtom: this is a hack to reset the text content, not sure how to do that right
this.uiObject.children.length = 0;
if (!richText || text.length === 0) {
//@TODO: @swingingtom how would the text content be set?
//@ts-ignore
this.uiObject.textContent = text;
}
else {
let currentTag = this.getNextTag(text);
if (!currentTag) {
//@ts-ignore
// we have to set it to empty string, otherwise TMUI won't update it @swingingtom
this.uiObject.textContent = ""; // <
this.setOptions({ textContent: text });
return;
}
else if (currentTag.startIndex > 0) {
// First segment should also clear children inlines
for (let i = this.uiObject.children.length - 1; i >= 0; i--) {
const child = this.uiObject.children[i];
// @ts-ignore
if (child.isUI) {
this.uiObject.remove(child);
child.clear();
}
}
const el = new ThreeMeshUI.Inline({ textContent: text.substring(0, currentTag.startIndex), color: 'inherit' });
this.uiObject.add(el);
}
const stackArray = [];
while (currentTag) {
const next = this.getNextTag(text, currentTag.endIndex);
const opts = {
fontFamily: this.uiObject?.get('fontFamily'),
color: 'inherit',
textContent: ""
};
if (next) {
opts.textContent = this.getText(text, currentTag, next);
this.handleTag(currentTag, opts, stackArray);
const el = new ThreeMeshUI.Inline(opts);
this.uiObject?.add(el);
}
else {
opts.textContent = text.substring(currentTag.endIndex);
this.handleTag(currentTag, opts, stackArray);
const el = new ThreeMeshUI.Inline(opts);
this.uiObject?.add(el);
}
currentTag = next;
}
}
}
_didHandleTextRenderOnTop = false;
handleTextRenderOnTop() {
if (this._didHandleTextRenderOnTop)
return;
this._didHandleTextRenderOnTop = true;
this.startCoroutine(this.renderOnTopCoroutine());
}
// waits for all the text objects to be ready to set the render on top setting
// @THH : this isn't true anymore. We can set mesh and material properties before their counterparts are created.
// Values would automatically be passed when created. Not sure for depthWrite but it can be added;
*renderOnTopCoroutine() {
if (!this.canvas)
return;
const updatedRendering = [];
const canvas = this.canvas;
const settings = {
renderOnTop: canvas.renderOnTop,
depthWrite: canvas.depthWrite,
doubleSided: canvas.doubleSided
};
while (true) {
let isWaitingForElementToUpdate = false;
if (this._textMeshUi) {
for (let i = 0; i < this._textMeshUi.length; i++) {
if (updatedRendering[i] === true)
continue;
isWaitingForElementToUpdate = true;
const textMeshObject = this._textMeshUi[i];
// text objects have this textContent which is the mesh
// it is not ready immediately so we have to check if it exists
// and only then setting the render on top property works
if (!textMeshObject["textContent"])
continue;
updateRenderSettings(textMeshObject, settings);
updatedRendering[i] = true;
// console.log(textMeshObject);
}
}
if (!isWaitingForElementToUpdate)
break;
yield;
}
}
handleTag(tag, opts, stackArray) {
// console.log(tag);
if (!tag.isEndTag) {
if (tag.type.includes("color")) {
const stackEntry = new TagStackEntry(tag, { color: opts.color });
stackArray.push(stackEntry);
if (tag.type.length > 6) // color=
{
const col = parseInt("0x" + tag.type.substring(7));
opts.color = col;
}
else {
// if it does not contain a color it is white
opts.color = new Color(1, 1, 1);
}
}
else if (tag.type == "b") {
this.setFont(opts, FontStyle.Bold);
const stackEntry = new TagStackEntry(tag, {
fontWeight: 700,
});
stackArray.push(stackEntry);
}
else if (tag.type == "i") {
this.setFont(opts, FontStyle.Italic);
const stackEntry = new TagStackEntry(tag, {
fontStyle: 'italic'
});
stackArray.push(stackEntry);
}
}
}
getText(text, start, end) {
return text.substring(start.endIndex, end.startIndex);
}
getNextTag(text, startIndex = 0) {
const start = text.indexOf("<", startIndex);
const end = text.indexOf(">", start);
if (start >= 0 && end >= 0) {
const tag = text.substring(start + 1, end);
return { type: tag, startIndex: start, endIndex: end + 1, isEndTag: tag.startsWith("/") };
}
return null;
}
/**
* Update provided opts to have a proper fontDefinition : family+weight+style
* Ensure Family and Variant are registered in FontLibrary
*
* @param opts
* @param fontStyle
* @private
*/
setFont(opts, fontStyle) {
// @TODO : THH could be useful to uniformize font family name :
// This would ease possible html/vr matching
// - Arial instead of assets/arial
// - Arial should stay Arial instead of arial
if (!this.font)
return;
const fontName = this.font;
const familyName = this.getFamilyNameWithCorrectSuffix(fontName, fontStyle);
if (debug)
console.log("Selected font family:" + familyName);
// ensure a font family is register under this name
let fontFamily = ThreeMeshUI.FontLibrary.getFontFamily(familyName);
if (!fontFamily)
fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(familyName);
// @TODO: @swingingtom how should the font be set?
//@ts-ignore
opts.fontFamily = fontFamily;
switch (fontStyle) {
default:
case FontStyle.Normal:
opts.fontWeight = 400;
opts.fontStyle = "normal";
break;
case FontStyle.Bold:
opts.fontWeight = 700;
opts.fontStyle = "normal";
break;
case FontStyle.Italic:
opts.fontWeight = 400;
opts.fontStyle = "italic";
break;
case FontStyle.BoldAndItalic:
opts.fontStyle = 'italic';
opts.fontWeight = 400;
}
// Ensure a fontVariant is registered
//@TODO: @swingingtom add type for fontWeight
let fontVariant = fontFamily.getVariant(opts.fontWeight, opts.fontStyle);
if (!fontVariant) {
let jsonPath = familyName;
if (!jsonPath?.endsWith("-msdf.json"))
jsonPath += "-msdf.json";
let texturePath = familyName;
if (!texturePath?.endsWith(".png"))
texturePath += ".png";
//@TODO: @swingingtom add type for fontWeight
//@TODO: @swingingtom addVariant return type is wrong (should be FontVariant)
fontVariant = fontFamily.addVariant(opts.fontWeight, opts.fontStyle, jsonPath, texturePath);
/** @ts-ignore */
fontVariant?.addEventListener('ready', () => {
this.markDirty();
});
}
}
getFamilyNameWithCorrectSuffix(familyName, style) {
// we can only change the style for the family if the name has a suffix (e.g. Arial-Bold)
const styleSeparator = familyName.lastIndexOf('-');
if (styleSeparator < 0)
return familyName;
// Check if the font name contains a style that we don't support in the enum
// e.g. -Medium, -Black, -Thin...
const styleName = familyName.substring(styleSeparator + 1)?.toLowerCase();
if (unsupportedStyleNames.includes(styleName)) {
if (debug)
console.warn("Unsupported font style: " + styleName);
return familyName;
}
// Try find a suffix that matches the style
// We assume that if the font name is "Arial-Regular" then the bold version is "Arial-Bold"
// and if the font name is "arial-regular" then the bold version is "arial-bold"
const pathSeparatorIndex = familyName.lastIndexOf("/");
let fontBaseName = familyName;
if (pathSeparatorIndex >= 0) {
fontBaseName = fontBaseName.substring(pathSeparatorIndex + 1);
}
const isUpperCase = fontBaseName[0] === fontBaseName[0].toUpperCase();
const fontNameWithoutSuffix = familyName.substring(0, styleSeparator);
if (debug)
console.log("Select font: ", familyName, FontStyle[style], fontBaseName, isUpperCase, fontNameWithoutSuffix);
switch (style) {
case FontStyle.Normal:
if (isUpperCase)
return fontNameWithoutSuffix + "-Regular";
else
return fontNameWithoutSuffix + "-regular";
case FontStyle.Bold:
if (isUpperCase)
return fontNameWithoutSuffix + "-Bold";
else
return fontNameWithoutSuffix + "-bold";
case FontStyle.Italic:
if (isUpperCase)
return fontNameWithoutSuffix + "-Italic";
else
return fontNameWithoutSuffix + "-italic";
case FontStyle.BoldAndItalic:
if (isUpperCase)
return fontNameWithoutSuffix + "-BoldItalic";
else
return fontNameWithoutSuffix + "-bolditalic";
default:
return familyName;
}
}
}
__decorate([
serializable()
], Text.prototype, "alignment", void 0);
__decorate([
serializable()
], Text.prototype, "verticalOverflow", void 0);
__decorate([
serializable()
], Text.prototype, "horizontalOverflow", void 0);
__decorate([
serializable()
], Text.prototype, "lineSpacing", void 0);
__decorate([
serializable()
], Text.prototype, "supportRichText", void 0);
__decorate([
serializable(URL)
], Text.prototype, "font", void 0);
__decorate([
serializable()
], Text.prototype, "fontStyle", void 0);
__decorate([
serializable()
], Text.prototype, "text", null);
__decorate([
serializable()
], Text.prototype, "fontSize", null);
class TagStackEntry {
tag;
previousValues;
constructor(tag, previousValues) {
this.tag = tag;
this.previousValues = previousValues;
}
}
// const anyTag = new RegExp('<.+?>', 'g');
// const regex = new RegExp('<(?<type>.+?)>(?<text>.+?)<\/.+?>', 'g');
const unsupportedStyleNames = [
"medium", "mediumitalic", "black", "blackitalic", "thin", "thinitalic", "extrabold", "light", "lightitalic", "semibold"
];
//# sourceMappingURL=Text.js.map