@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.
579 lines (491 loc) • 21.4 kB
text/typescript
import { Color } from 'three';
import * as ThreeMeshUI from 'three-mesh-ui'
import type { DocumentedOptions as ThreeMeshUIEveryOptions } from "three-mesh-ui/build/types/core/elements/MeshUIBaseElement.js";
import { serializable } from '../../engine/engine_serialization_decorator.js';
import { getParam } from '../../engine/engine_utils.js';
import { Canvas } from './Canvas.js';
import { Graphic } from './Graphic.js';
import { type ICanvas, type ICanvasEventReceiver, type IHasAlphaFactor } from './Interfaces.js';
import { updateRenderSettings } from './Utils.js';
const debug = getParam("debugtext");
export enum TextAnchor {
UpperLeft = 0,
UpperCenter = 1,
UpperRight = 2,
MiddleLeft = 3,
MiddleCenter = 4,
MiddleRight = 5,
LowerLeft = 6,
LowerCenter = 7,
LowerRight = 8,
}
export enum VerticalWrapMode {
Truncate = 0,
Overflow = 1,
}
enum HorizontalWrapMode {
Wrap = 0,
Overflow = 1,
}
export enum FontStyle {
Normal = 0,
Bold = 1,
Italic = 2,
BoldAndItalic = 3,
}
/**
* @category User Interface
* @group Components
*/
export class Text extends Graphic implements IHasAlphaFactor, ICanvasEventReceiver {
alignment: TextAnchor = TextAnchor.UpperLeft;
verticalOverflow: VerticalWrapMode = VerticalWrapMode.Truncate;
horizontalOverflow: HorizontalWrapMode = HorizontalWrapMode.Wrap;
lineSpacing: number = 1;
supportRichText: boolean = false;
font?: string;
fontStyle: FontStyle = FontStyle.Normal;
// private _alphaFactor : number = 1;
setAlphaFactor(factor: number): void {
super.setAlphaFactor(factor);
this.uiObject?.set({ fontOpacity: this.color.alpha * this.alphaFactor });
this.markDirty();
}
get text(): string {
return this._text;
}
set text(val: string) {
if (val !== this._text) {
this._text = val;
this.feedText(this.text, this.supportRichText);
this.markDirty();
}
}
private set_text(val: string) {
this.text = val;
}
get fontSize(): number {
return this._fontSize;
}
set fontSize(val: number) {
// Setting that kind of property in a parent, would cascade to each 'non-overrided' children.
this._fontSize = val;
this.uiObject?.set({ fontSize: val });
}
private sRGBTextColor: Color = new Color(1, 0, 1);
protected onColorChanged(): void {
this.sRGBTextColor.copy(this.color);
this.sRGBTextColor.convertLinearToSRGB();
this.uiObject?.set({ color: this.sRGBTextColor, fontOpacity: this.color.alpha });
}
onParentRectTransformChanged(): void {
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: ICanvas) {
// ensure the text clipping matrix is updated (this was a problem with multiple screenspace canvases due to canvas reparenting)
this.updateOverflow();
}
private updateOverflow() {
// HACK: force the text overflow to update
const overflow = (this.uiObject as any)?._overflow;
if (overflow) {
overflow._needsUpdate = true;
// the screenspace canvas does force an update, no need to mark dirty here
}
}
protected onCreate(_opts: any): void {
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();
}
private _text: string = "";
private _fontSize: number = 12;
private _textMeshUi: Array<ThreeMeshUI.Inline> | null = null;
private getTextOpts(): object {
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 as ThreeMeshUIEveryOptions, this.fontStyle);
return textOpts;
}
onEnable(): void {
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(): void {
super.onDisable();
this.canvas?.unregisterEventReceiver(this);
}
private getAlignment(opts: ThreeMeshUIEveryOptions): ThreeMeshUIEveryOptions {
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;
}
private feedText(text: string, richText: boolean) : void {
// 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 as any);
child.clear();
}
}
const el = new ThreeMeshUI.Inline({ textContent: text.substring(0, currentTag.startIndex), color: 'inherit' });
this.uiObject.add(el as any);
}
const stackArray: Array<TagStackEntry> = [];
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 as any)
} else {
opts.textContent = text.substring(currentTag.endIndex);
this.handleTag(currentTag, opts, stackArray);
const el = new ThreeMeshUI.Inline(opts);
this.uiObject?.add(el as any);
}
currentTag = next;
}
}
}
private _didHandleTextRenderOnTop: boolean = false;
private 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;
private * renderOnTopCoroutine() {
if (!this.canvas) return;
const updatedRendering: boolean[] = [];
const canvas = this.canvas as 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;
}
}
private handleTag(tag: TagInfo, opts: any, stackArray: Array<TagStackEntry>) {
// 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);
}
}
}
private getText(text: string, start: TagInfo, end: TagInfo) {
return text.substring(start.endIndex, end.startIndex);
}
private getNextTag(text: string, startIndex: number = 0): TagInfo | null {
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
*/
private setFont(opts: ThreeMeshUIEveryOptions, fontStyle: 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 as string);
if (!fontFamily)
fontFamily = ThreeMeshUI.FontLibrary.addFontFamily(familyName as string);
// @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 as any as string, 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 as any as string, opts.fontStyle, jsonPath, texturePath as string) as any as ThreeMeshUI.FontVariant;
/** @ts-ignore */
fontVariant?.addEventListener('ready', () => {
this.markDirty();
});
}
}
private getFamilyNameWithCorrectSuffix(familyName: string, style: FontStyle): string {
// 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;
}
}
}
class TagStackEntry {
tag: TagInfo;
previousValues: object;
constructor(tag: TagInfo, previousValues: object) {
this.tag = tag;
this.previousValues = previousValues;
}
}
declare type TagInfo = {
type: string,
startIndex: number,
endIndex: number,
isEndTag: boolean
}
// const anyTag = new RegExp('<.+?>', 'g');
// const regex = new RegExp('<(?<type>.+?)>(?<text>.+?)<\/.+?>', 'g');
const unsupportedStyleNames = [
"medium", "mediumitalic", "black", "blackitalic", "thin", "thinitalic", "extrabold", "light", "lightitalic", "semibold"
]