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.

748 lines (652 loc) • 28.7 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, } export enum HorizontalWrapMode { Wrap = 0, Overflow = 1, } export enum FontStyle { Normal = 0, Bold = 1, Italic = 2, BoldAndItalic = 3, } /** * [Text](https://engine.needle.tools/docs/api/Text) displays text content in the UI. Supports custom fonts, colors, * alignment, and basic rich text formatting. * * **Text properties:** * - `text` - The string content to display * - `fontSize` - Size of the text in pixels * - `color` - Text color (inherited from Graphic) * - `alignment` - Text anchor position (UpperLeft, MiddleCenter, etc.) * * **Fonts:** * Set the `font` property to a URL pointing to a font file. * Supports MSDF (Multi-channel Signed Distance Field) fonts for crisp rendering. * * @example Update text at runtime * ```ts * const text = myLabel.getComponent(Text); * text.text = "Score: " + score; * text.fontSize = 24; * text.color = new RGBAColor(1, 1, 1, 1); * ``` * * @summary Display text in the UI * @category User Interface * @group Components * @see {@link Canvas} for the UI root * @see {@link TextAnchor} for alignment options * @see {@link FontStyle} for bold/italic styles */ export class Text extends Graphic implements IHasAlphaFactor, ICanvasEventReceiver { /** * The alignment of the text within its container. This determines how the text is anchored and positioned relative to its RectTransform. For example, `UpperLeft` will anchor the text to the upper left corner of the container, while `MiddleCenter` will center the text both horizontally and vertically. Changing this property will update the underlying three-mesh-ui options and mark the UI as dirty to trigger a re-render. */ @serializable() set alignment(val: TextAnchor) { if (val !== this._alignment) { this._alignment = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get alignment(): TextAnchor { return this._alignment; } private _alignment: TextAnchor = TextAnchor.UpperLeft; /** * Determines how text that exceeds the vertical bounds of the container is handled. If set to `Truncate`, any text that goes beyond the vertical limits of the container will be cut off and not visible. If set to `Overflow`, the text will continue to render outside the container bounds, which may result in it being partially or fully visible depending on the layout and parent containers. * @default VerticalWrapMode.Truncate */ @serializable() set verticalOverflow(val: VerticalWrapMode) { if (val !== this._verticalOverflow) { this._verticalOverflow = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get verticalOverflow(): VerticalWrapMode { return this._verticalOverflow; } private _verticalOverflow: VerticalWrapMode = VerticalWrapMode.Truncate; /** * Determines how text that exceeds the horizontal bounds of the container is handled. If set to `Wrap`, the text will automatically wrap to the next line when it reaches the edge of the container. If set to `Overflow`, the text will continue on a single line and may overflow outside the container bounds. * @default HorizontalWrapMode.Wrap */ @serializable() set horizontalOverflow(val: HorizontalWrapMode) { if (val !== this._horizontalOverflow) { this._horizontalOverflow = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get horizontalOverflow(): HorizontalWrapMode { return this._horizontalOverflow; } private _horizontalOverflow: HorizontalWrapMode = HorizontalWrapMode.Wrap; /** * The line spacing multiplier for the text. A value of 1 means normal spacing, 1.5 means 50% more spacing, and 0.5 means 50% less spacing. * @default 1 */ @serializable() set lineSpacing(val: number) { if (val !== this._lineSpacing) { this._lineSpacing = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get lineSpacing(): number { return this._lineSpacing; } private _lineSpacing: number = 1; /** * The style of the font, which can be normal, bold, italic, or bold and italic. * * This is used to determine the correct font variant to use based on the provided `font` property. For example, if you set `font` to "Arial" and `fontStyle` to `FontStyle.Bold`, it will look for a font variant named "Arial-Bold" (or "arial-bold") in the font library. If such a variant exists, it will be used to render the text with the specified style. * * @default FontStyle.Normal */ @serializable() set fontStyle(val: FontStyle) { if (val !== this._fontStyle) { this._fontStyle = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get fontStyle(): FontStyle { return this._fontStyle; } private _fontStyle: FontStyle = FontStyle.Normal; /** * The font to use for the text, specified as a URL to a font file. * * The font file must be in MSDF format. You can generate MSDF fonts using tools like [msdf-bmfont-xml](https://github.com/Chlumsky/msdf-bmfont-xml) or by using Needle Engine for Unity where the Needle Engine integration automatically generates MSDF fonts for used font assets. * * @default "https://cdn.needle.tools/static/fonts/msdf/arial/arial" */ @serializable(URL) set font(val: string | null) { if (val !== this._font) { this._font = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); // If the font is assigned during deserialization it means the font URL MUST be resolved with the style suffix. If it's assigned at runtime by a user invocation and is absolute then we assume the user is prividing a full path to the font asset json or png this._assignedAtRuntime = this.__didAwake; } } get font(): string | null { return this._font; } private _font: string | null = "https://cdn.needle.tools/static/fonts/msdf/arial/arial"; private _assignedAtRuntime: boolean = true; /** * Whether to support basic rich text tags in the `text` property. Supported tags include `<b>`, `<i>`, and `<color=hex>`. For example: `Hello <b>World</b>` or `Score: <color=#ff0000>100</color>` * @default false */ @serializable() set supportRichText(val: boolean) { if (val !== this._supportRichText) { this._supportRichText = val; this.uiObject?.set(this.getTextOpts()); this.markDirty(); } } get supportRichText(): boolean { return this._supportRichText; } private _supportRichText: boolean = false; /** * Set the alpha factor for the text, which multiplies with the color's alpha to determine overall transparency. */ setAlphaFactor(factor: number): void { super.setAlphaFactor(factor); this.uiObject?.set({ fontOpacity: this.color.alpha * this.alphaFactor }); this.markDirty(); } /** * The text content to display. Supports basic rich text tags like `<b>`, `<i>`, and `<color=hex>` if `supportRichText` is enabled. * @default "" */ @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(); this.context.accessibility.updateElement(this, { label: this.text }); } } private _text: string = ""; 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 _fontSize: number = 12; 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("[Text] onCreate", this); // @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; } // 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; } const rt = this.rectTransform; this.uiObject = rt.createNewText(_opts); this.feedText(this.text, this.supportRichText); } onAfterAddedToScene() { super.onAfterAddedToScene(); this.handleTextRenderOnTop(); } private _textMeshUi: Array<ThreeMeshUI.Inline> | null = null; private getTextOpts(): object { const fontSize = this.fontSize; // if (this.canvas) { // fontSize /= this.canvas?.scaleFactor; // } const textOpts: { color: Color, fontOpacity: number, fontSize: number, fontKerning: string, whiteSpace?: string, overflow?: string, lineHeight?: number } = { color: this.color, fontOpacity: this.color.alpha, fontSize: fontSize, fontKerning: "normal", }; if (this.horizontalOverflow == HorizontalWrapMode.Overflow) { // Only line characters in the textContent (\n,\r\t) would be able to multiline the text textOpts.whiteSpace = 'pre'; } if (this.verticalOverflow == VerticalWrapMode.Truncate) { this.context.renderer.localClippingEnabled = true; textOpts.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 // textOpts.width = 'auto'; } textOpts.lineHeight = this.lineSpacing; this.setFont(textOpts as ThreeMeshUIEveryOptions, this.fontStyle); return textOpts; } onEnable(): void { super.onEnable(); this.context.accessibility.updateElement(this, { role: "text", label: this.text, hidden: false }); 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); this.context.accessibility.updateElement(this, { hidden: true }); } onDestroy(): void { super.onDestroy(); this.context.accessibility.removeElement(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("[Text] 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) { if (!this.font) { if (debug) console.warn("[Text] No font set for Text component, skipping font setup"); return; } const fontName = this.font; const familyName = this.getFamilyNameWithCorrectSuffix(fontName, fontStyle); if (debug) console.log(`[Text] Selected font family '${familyName}' from ${fontName} with style ${FontStyle[fontStyle]}`); // 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 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 { const isAbsolute = familyName.startsWith("https:") || familyName.startsWith("http:"); // 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("[Text] 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 > pathSeparatorIndex ? styleSeparator : familyName.length); if (debug) console.log("[Text] Select font: ", familyName, FontStyle[style], fontBaseName, isUpperCase, fontNameWithoutSuffix); /** * If a user provides a font with an absolute URL AND the font name does not end with "-msdf.json" or ".png" (e.g. "https://example.com/fonts/Arial-Bold"), we will assume that the user is providing the full path to the font files and we will not try to modify the font name based on the style. This allows users to have more control over the font files they are using, especially if they are hosting their own fonts or using a custom font provider that does not follow the same naming conventions as our default fonts. */ if (isAbsolute && this._assignedAtRuntime && !(familyName.endsWith("-msdf.json") || familyName.endsWith(".png"))) { return 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" ]