UNPKG

@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
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 { @serializable() alignment: TextAnchor = TextAnchor.UpperLeft; @serializable() verticalOverflow: VerticalWrapMode = VerticalWrapMode.Truncate; @serializable() horizontalOverflow: HorizontalWrapMode = HorizontalWrapMode.Wrap; @serializable() lineSpacing: number = 1; @serializable() supportRichText: boolean = false; @serializable(URL) font?: string; @serializable() 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(); } @serializable() 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; } @serializable() 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" ]