• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-element / 16214082367

11 Jul 2025 07:07AM UTC coverage: 68.182% (+1.1%) from 67.128%
16214082367

push

github

apowers313
refactor: extract mesh creation logic into NodeMesh and EdgeMesh classes

546 of 780 branches covered (70.0%)

Branch coverage included in aggregate %.

333 of 334 new or added lines in 3 files covered. (99.7%)

80 existing lines in 5 files now uncovered.

3954 of 5820 relevant lines covered (67.94%)

994.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.29
/src/RichTextLabel.ts
1
import {AbstractMesh, Color3, DynamicTexture, Engine, Mesh, MeshBuilder, Scene, StandardMaterial, Texture, Vector3} from "@babylonjs/core";
1✔
2

3
import {BadgeStyleManager} from "./BadgeStyleManager.ts";
1✔
4
import {type ContentArea as PointerContentArea, type PointerDirection, PointerRenderer} from "./PointerRenderer.ts";
1✔
5
import {RichTextAnimator} from "./RichTextAnimator.ts";
1✔
6
import {RichTextParser} from "./RichTextParser.ts";
1✔
7
import {RichTextRenderer} from "./RichTextRenderer.ts";
1✔
8

9
export type BadgeType = "notification" | "label" | "label-success" | "label-warning" | "label-danger" | "count" | "icon" | "progress" | "dot" | undefined;
10
export type AttachPosition = "top" | "bottom" | "left" | "right" | "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
11

12
export interface RichTextStyle {
13
    font: string;
14
    size: number;
15
    weight: string;
16
    style: string;
17
    color: string;
18
    background: string | null;
19
}
20

21
export interface TextSegment {
22
    text: string;
23
    style: RichTextStyle;
24
}
25

26
interface PointerInfo {
27
    direction: string;
28
    width: number;
29
    height: number;
30
    offset: number;
31
    curve: boolean;
32
}
33

34
interface Border {
35
    width: number;
36
    color: string;
37
    spacing: number;
38
}
39

40
interface ActualDimensions {
41
    width: number;
42
    height: number;
43
}
44

45
interface ContentArea {
46
    x: number;
47
    y: number;
48
    width: number;
49
    height: number;
50
}
51

52
export interface RichTextLabelOptions {
53
    text?: string;
54
    position?: {x: number, y: number, z: number};
55
    resolution?: number;
56
    autoSize?: boolean;
57
    font?: string;
58
    fontSize?: number;
59
    fontWeight?: string;
60
    textColor?: string;
61
    textAlign?: "left" | "center" | "right";
62
    lineHeight?: number;
63
    backgroundColor?: string;
64
    backgroundGradient?: boolean;
65
    backgroundGradientColors?: string[];
66
    backgroundGradientType?: "linear" | "radial";
67
    backgroundGradientDirection?: "vertical" | "horizontal" | "diagonal";
68
    backgroundPadding?: number;
69
    marginTop?: number;
70
    marginBottom?: number;
71
    marginLeft?: number;
72
    marginRight?: number;
73
    borderWidth?: number;
74
    borderColor?: string;
75
    borders?: Border[];
76
    cornerRadius?: number;
77
    animation?: "none" | "pulse" | "bounce" | "shake" | "glow" | "fill";
78
    animationSpeed?: number;
79
    billboardMode?: number;
80
    textShadow?: boolean;
81
    textShadowColor?: string;
82
    textShadowBlur?: number;
83
    textShadowOffsetX?: number;
84
    textShadowOffsetY?: number;
85
    textOutline?: boolean;
86
    textOutlineColor?: string;
87
    textOutlineWidth?: number;
88
    textOutlineJoin?: CanvasLineJoin;
89
    pointer?: boolean;
90
    pointerDirection?: "top" | "bottom" | "left" | "right" | "auto";
91
    pointerWidth?: number;
92
    pointerHeight?: number;
93
    pointerOffset?: number;
94
    pointerCurve?: boolean;
95
    attachTo?: AbstractMesh | Vector3;
96
    attachPosition?: AttachPosition;
97
    attachOffset?: number;
98
    depthFadeEnabled?: boolean;
99
    depthFadeNear?: number;
100
    depthFadeFar?: number;
101
    badge?: BadgeType;
102
    icon?: string;
103
    iconPosition?: "left" | "right";
104
    progress?: number;
105
    smartOverflow?: boolean;
106
    maxNumber?: number;
107
    overflowSuffix?: string;
108
    _badgeType?: BadgeType;
109
    _smartSizing?: boolean;
110
    _paddingRatio?: number;
111
    _removeText?: boolean;
112
    _progressBar?: boolean;
113
}
114

115
type ResolvedRichTextLabelOptions = Omit<Required<RichTextLabelOptions>, "badge" | "icon" | "progress" | "attachTo" | "_badgeType" | "_smartSizing" | "_paddingRatio" | "_removeText" | "_progressBar"> & {
116
    badge: BadgeType;
117
    icon: string | undefined;
118
    progress: number | undefined;
119
    attachTo: AbstractMesh | Vector3 | undefined;
120
    _badgeType: BadgeType;
121
    _smartSizing: boolean | undefined;
122
    _paddingRatio: number | undefined;
123
    _removeText: boolean | undefined;
124
    _progressBar: boolean | undefined;
125
};
126

127
export class RichTextLabel {
1✔
128
    private scene: Scene;
129
    private options: ResolvedRichTextLabelOptions;
130
    private mesh: Mesh | null = null;
1✔
131
    private texture: DynamicTexture | null = null;
1✔
132
    private material: StandardMaterial | null = null;
1✔
133
    private parsedContent: TextSegment[][] = [];
1✔
134
    private actualDimensions: ActualDimensions = {width: 0, height: 0};
1✔
135
    private contentArea: ContentArea = {x: 0, y: 0, width: 0, height: 0};
1✔
136
    private totalBorderWidth = 0;
1✔
137
    private pointerInfo: PointerInfo | null = null;
1✔
138
    private _progressValue = 0;
1✔
139
    private id: string;
140
    private originalPosition: Vector3 | null = null;
1✔
141

142
    private parser: RichTextParser;
143
    private renderer: RichTextRenderer;
144
    private animator: RichTextAnimator | null = null;
1✔
145
    private pointerRenderer: PointerRenderer;
146

147
    static createLabel(scene: Scene, userOptions: RichTextLabelOptions): RichTextLabel {
1✔
148
        return new RichTextLabel(scene, userOptions);
1✔
149
    }
1✔
150

151
    constructor(scene: Scene, userOptions: RichTextLabelOptions) {
1✔
152
        this.scene = scene;
2,162✔
153

154
        const defaultOptions: ResolvedRichTextLabelOptions = {
2,162✔
155
            text: "Label",
2,162✔
156
            position: {x: 0, y: 0, z: 0},
2,162✔
157
            resolution: 1024,
2,162✔
158
            autoSize: true,
2,162✔
159
            font: "Verdana",
2,162✔
160
            fontSize: 48,
2,162✔
161
            fontWeight: "normal",
2,162✔
162
            textColor: "black",
2,162✔
163
            textAlign: "center",
2,162✔
164
            lineHeight: 1.2,
2,162✔
165
            backgroundColor: "transparent",
2,162✔
166
            backgroundGradient: false,
2,162✔
167
            backgroundGradientColors: ["rgba(0, 0, 0, 0.8)", "rgba(50, 50, 50, 0.8)"],
2,162✔
168
            backgroundGradientType: "linear",
2,162✔
169
            backgroundGradientDirection: "vertical",
2,162✔
170
            backgroundPadding: 0,
2,162✔
171
            marginTop: 5,
2,162✔
172
            marginBottom: 5,
2,162✔
173
            marginLeft: 5,
2,162✔
174
            marginRight: 5,
2,162✔
175
            borderWidth: 0,
2,162✔
176
            borderColor: "rgba(255, 255, 255, 0.8)",
2,162✔
177
            borders: [],
2,162✔
178
            cornerRadius: 0,
2,162✔
179
            animation: "none",
2,162✔
180
            animationSpeed: 1,
2,162✔
181
            billboardMode: Mesh.BILLBOARDMODE_ALL,
2,162✔
182
            textShadow: false,
2,162✔
183
            textShadowColor: "rgba(0, 0, 0, 0.5)",
2,162✔
184
            textShadowBlur: 4,
2,162✔
185
            textShadowOffsetX: 2,
2,162✔
186
            textShadowOffsetY: 2,
2,162✔
187
            textOutline: false,
2,162✔
188
            textOutlineColor: "black",
2,162✔
189
            textOutlineWidth: 2,
2,162✔
190
            textOutlineJoin: "round" as CanvasLineJoin,
2,162✔
191
            pointer: false,
2,162✔
192
            pointerDirection: "bottom",
2,162✔
193
            pointerWidth: 20,
2,162✔
194
            pointerHeight: 15,
2,162✔
195
            pointerOffset: 0,
2,162✔
196
            pointerCurve: true,
2,162✔
197
            attachTo: undefined as AbstractMesh | Vector3 | undefined,
2,162✔
198
            attachPosition: "top",
2,162✔
199
            attachOffset: 0.5,
2,162✔
200
            depthFadeEnabled: false,
2,162✔
201
            depthFadeNear: 5,
2,162✔
202
            depthFadeFar: 20,
2,162✔
203
            badge: undefined,
2,162✔
204
            icon: undefined,
2,162✔
205
            iconPosition: "left",
2,162✔
206
            progress: undefined,
2,162✔
207
            smartOverflow: false,
2,162✔
208
            maxNumber: 999,
2,162✔
209
            overflowSuffix: "+",
2,162✔
210
            _badgeType: undefined,
2,162✔
211
            _smartSizing: undefined,
2,162✔
212
            _paddingRatio: undefined,
2,162✔
213
            _removeText: undefined,
2,162✔
214
            _progressBar: undefined,
2,162✔
215
        };
2,162✔
216

217
        const finalOptions = Object.assign({}, defaultOptions, userOptions) as ResolvedRichTextLabelOptions;
2,162✔
218

219
        if (finalOptions.badge) {
2,162✔
220
            const badgeDefaults = BadgeStyleManager.getBadgeStyle(finalOptions.badge);
77✔
221
            if (badgeDefaults) {
77✔
222
                Object.assign(finalOptions, badgeDefaults, userOptions);
77✔
223
            }
77✔
224
        }
77✔
225

226
        BadgeStyleManager.applyBadgeBehaviors(finalOptions, userOptions);
2,162✔
227

228
        if (finalOptions.borderWidth > 0 && finalOptions.borders.length === 0) {
2,162✔
229
            finalOptions.borders = [{
77✔
230
                width: finalOptions.borderWidth,
77✔
231
                color: finalOptions.borderColor,
77✔
232
                spacing: 0,
77✔
233
            }];
77✔
234
        }
77✔
235

236
        this.options = finalOptions;
2,162✔
237
        this.id = `richLabel_${Math.random().toString(36).substring(2, 11)}`;
2,162✔
238

239
        if (userOptions.progress !== undefined) {
2,162!
240
            this._progressValue = Math.max(0, Math.min(1, userOptions.progress));
×
241
        }
×
242

243
        this.parser = new RichTextParser({
2,162✔
244
            font: this.options.font,
2,162✔
245
            size: this.options.fontSize,
2,162✔
246
            weight: this.options.fontWeight,
2,162✔
247
            style: "normal",
2,162✔
248
            color: this.options.textColor,
2,162✔
249
            background: null,
2,162✔
250
        });
2,162✔
251

252
        this.renderer = new RichTextRenderer({
2,162✔
253
            textAlignment: this.options.textAlign,
2,162✔
254
            marginLeft: this.options.marginLeft,
2,162✔
255
            marginRight: this.options.marginRight,
2,162✔
256
            marginTop: this.options.marginTop,
2,162✔
257
            marginBottom: this.options.marginBottom,
2,162✔
258
            backgroundPadding: this.options.backgroundPadding,
2,162✔
259
            lineHeight: this.options.lineHeight,
2,162✔
260
            textShadow: this.options.textShadow,
2,162✔
261
            textShadowColor: this.options.textShadowColor,
2,162✔
262
            textShadowBlur: this.options.textShadowBlur,
2,162✔
263
            textShadowOffsetX: this.options.textShadowOffsetX,
2,162✔
264
            textShadowOffsetY: this.options.textShadowOffsetY,
2,162✔
265
            textOutline: this.options.textOutline,
2,162✔
266
            textOutlineColor: this.options.textOutlineColor,
2,162✔
267
            textOutlineWidth: this.options.textOutlineWidth,
2,162✔
268
            textOutlineJoin: this.options.textOutlineJoin,
2,162✔
269
        });
2,162✔
270

271
        this.pointerRenderer = new PointerRenderer();
2,162✔
272

273
        if (this.options.animation !== "none") {
2,162✔
274
            this.animator = new RichTextAnimator(this.scene, {
154✔
275
                animation: this.options.animation,
154✔
276
                animationSpeed: this.options.animationSpeed,
154✔
277
            });
154✔
278
        }
154✔
279

280
        this._create();
2,162✔
281
    }
2,162✔
282

283
    private _create(): void {
1✔
284
        this._parseRichText();
2,162✔
285
        this._calculateDimensions();
2,162✔
286
        this._createTexture();
2,162✔
287
        this._createMaterial();
2,162✔
288
        this._createMesh();
2,162✔
289

290
        if (this.options.attachTo) {
2,162✔
291
            this._attachToTarget();
2,162✔
292
        } else if (this.mesh) {
2,162!
293
            this.mesh.position = new Vector3(
×
294
                this.options.position.x,
×
295
                this.options.position.y,
×
296
                this.options.position.z,
×
297
            );
×
298
            this.originalPosition ??= this.mesh.position.clone();
×
299
        }
×
300

301
        if (this.options.depthFadeEnabled) {
2,162✔
302
            this._setupDepthFading();
77✔
303
        }
77✔
304

305
        if (this.animator && this.mesh && this.material) {
2,162✔
306
            this.animator.setupAnimation(this.mesh, this.material, (value) => {
154✔
307
                this._progressValue = value;
×
308
                this._drawContent();
×
309
            });
154✔
310
        }
154✔
311
    }
2,162✔
312

313
    private _parseRichText(): void {
1✔
314
        this.parsedContent = this.parser.parse(this.options.text);
2,162✔
315
    }
2,162✔
316

317
    private _calculateDimensions(): void {
1✔
318
        const tempCanvas = document.createElement("canvas");
2,162✔
319
        const tempCtx = tempCanvas.getContext("2d");
2,162✔
320
        if (!tempCtx) {
2,162!
321
            return;
×
322
        }
×
323

324
        const {maxWidth, totalHeight} = this.parser.measureText(this.parsedContent, tempCtx, {
2,162✔
325
            lineHeight: this.options.lineHeight,
2,162✔
326
            textOutline: this.options.textOutline,
2,162✔
327
            textOutlineWidth: this.options.textOutlineWidth,
2,162✔
328
        });
2,162✔
329

330
        const bgPadding = this.options.backgroundPadding * 2;
2,162✔
331

332
        this.totalBorderWidth = 0;
2,162✔
333
        if (this.options.borders.length > 0) {
2,162✔
334
            for (let i = 0; i < this.options.borders.length; i++) {
77✔
335
                this.totalBorderWidth += this.options.borders[i].width;
77✔
336
                if (i < this.options.borders.length - 1 && this.options.borders[i].spacing > 0) {
77!
337
                    this.totalBorderWidth += this.options.borders[i].spacing;
×
338
                }
×
339
            }
77✔
340
        }
77✔
341

342
        const contentWidth = maxWidth + this.options.marginLeft + this.options.marginRight + bgPadding;
2,162✔
343
        const contentHeight = totalHeight + this.options.marginTop + this.options.marginBottom + bgPadding;
2,162✔
344

345
        this.contentArea = {
2,162✔
346
            x: this.totalBorderWidth,
2,162✔
347
            y: this.totalBorderWidth,
2,162✔
348
            width: contentWidth,
2,162✔
349
            height: contentHeight,
2,162✔
350
        };
2,162✔
351

352
        this.actualDimensions.width = contentWidth + (this.totalBorderWidth * 2);
2,162✔
353
        this.actualDimensions.height = contentHeight + (this.totalBorderWidth * 2);
2,162✔
354

355
        if (this.options.pointer) {
2,162✔
356
            this._calculatePointerDimensions();
77✔
357

358
            if (this.pointerInfo) {
77✔
359
                switch (this.pointerInfo.direction) {
77✔
360
                    case "top":
77!
361
                        this.actualDimensions.height += this.options.pointerHeight;
×
362
                        this.contentArea.y = this.totalBorderWidth + this.options.pointerHeight;
×
363
                        break;
×
364
                    case "bottom":
77✔
365
                        this.actualDimensions.height += this.options.pointerHeight;
77✔
366
                        break;
77✔
367
                    case "left":
77!
368
                        this.actualDimensions.width += this.options.pointerHeight;
×
369
                        this.contentArea.x = this.totalBorderWidth + this.options.pointerHeight;
×
370
                        break;
×
371
                    case "right":
77!
372
                        this.actualDimensions.width += this.options.pointerHeight;
×
373
                        break;
×
374
                    default:
77!
375
                        break;
×
376
                }
77✔
377
            }
77✔
378
        }
77✔
379

380
        if (this.options._smartSizing) {
2,162✔
381
            const minDimension = this.actualDimensions.height;
77✔
382
            if (this.actualDimensions.width < minDimension) {
77!
383
                const extraWidth = minDimension - this.actualDimensions.width;
×
384
                this.actualDimensions.width = minDimension;
×
385
                this.contentArea.x += extraWidth / 2;
×
386
            }
×
387
        }
77✔
388
    }
2,162✔
389

390
    private _calculatePointerDimensions(): void {
1✔
391
        let direction = this.options.pointerDirection;
77✔
392

393
        if (direction === "auto") {
77!
394
            direction = "bottom";
×
395
        }
×
396

397
        this.pointerInfo = {
77✔
398
            direction: direction,
77✔
399
            width: this.options.pointerWidth,
77✔
400
            height: this.options.pointerHeight,
77✔
401
            offset: this.options.pointerOffset,
77✔
402
            curve: this.options.pointerCurve,
77✔
403
        };
77✔
404
    }
77✔
405

406
    private _createTexture(): void {
1✔
407
        const MAX_TEXTURE_SIZE = 4096;
2,162✔
408

409
        let textureWidth = this.options.autoSize ?
2,162✔
410
            Math.pow(2, Math.ceil(Math.log2(this.actualDimensions.width))) :
2,162!
411
            this.options.resolution;
×
412

413
        const aspectRatio = this.actualDimensions.width / this.actualDimensions.height;
2,162✔
414
        let textureHeight = this.options.autoSize ?
2,162✔
415
            Math.pow(2, Math.ceil(Math.log2(this.actualDimensions.height))) :
2,162!
416
            Math.floor(textureWidth / aspectRatio);
×
417

418
        if (textureWidth > MAX_TEXTURE_SIZE || textureHeight > MAX_TEXTURE_SIZE) {
2,162!
419
            const scale = MAX_TEXTURE_SIZE / Math.max(textureWidth, textureHeight);
×
420
            textureWidth = Math.floor(textureWidth * scale);
×
421
            textureHeight = Math.floor(textureHeight * scale);
×
422
            console.warn(`RichTextLabel: Texture size clamped to ${textureWidth}x${textureHeight} (max: ${MAX_TEXTURE_SIZE})`);
×
423
        }
×
424

425
        this.texture = new DynamicTexture(`richTextTexture_${this.id}`, {
2,162✔
426
            width: textureWidth,
2,162✔
427
            height: textureHeight,
2,162✔
428
        }, this.scene, true);
2,162✔
429

430
        this.texture.hasAlpha = true;
2,162✔
431
        this.texture.updateSamplingMode(Texture.TRILINEAR_SAMPLINGMODE);
2,162✔
432

433
        this._drawContent();
2,162✔
434
    }
2,162✔
435

436
    private _drawContent(): void {
1✔
437
        if (!this.texture) {
2,162!
438
            return;
×
439
        }
×
440

441
        const ctx = this.texture.getContext() as unknown as CanvasRenderingContext2D;
2,162✔
442
        const {width} = this.texture.getSize();
2,162✔
443
        const {height} = this.texture.getSize();
2,162✔
444

445
        ctx.clearRect(0, 0, width, height);
2,162✔
446

447
        const scaleX = width / this.actualDimensions.width;
2,162✔
448
        const scaleY = height / this.actualDimensions.height;
2,162✔
449
        ctx.save();
2,162✔
450
        ctx.scale(scaleX, scaleY);
2,162✔
451

452
        if (this.options.pointer) {
2,162✔
453
            this._drawBackgroundWithPointer(ctx);
77✔
454
        } else {
2,162✔
455
            this._drawBackgroundWithBorders(ctx);
2,085✔
456
        }
2,085✔
457

458
        this.renderer.drawText(ctx, this.parsedContent, this.contentArea);
2,162✔
459

460
        ctx.restore();
2,162✔
461
        this.texture.update();
2,162✔
462
    }
2,162✔
463

464
    private _drawBackgroundWithBorders(ctx: CanvasRenderingContext2D): void {
1✔
465
        const {width} = this.actualDimensions;
2,085✔
466
        const {height} = this.actualDimensions;
2,085✔
467
        const radius = this.options.cornerRadius;
2,085✔
468

469
        if (this.options.borders.length > 0) {
2,085✔
470
            let currentOffset = 0;
77✔
471

472
            for (let i = 0; i < this.options.borders.length; i++) {
77✔
473
                const border = this.options.borders[i];
77✔
474

475
                if (i > 0 && this.options.borders[i - 1].spacing > 0) {
77!
476
                    currentOffset += this.options.borders[i - 1].spacing;
×
477
                }
×
478

479
                ctx.save();
77✔
480
                ctx.fillStyle = border.color;
77✔
481

482
                const x = currentOffset;
77✔
483
                const y = currentOffset;
77✔
484
                const w = width - (currentOffset * 2);
77✔
485
                const h = height - (currentOffset * 2);
77✔
486
                const r = Math.max(0, radius - currentOffset);
77✔
487

488
                this._createRoundedRectPath(ctx, x, y, w, h, r);
77✔
489

490
                const innerOffset = currentOffset + border.width;
77✔
491
                if (i < this.options.borders.length - 1 || innerOffset < this.totalBorderWidth) {
77!
492
                    let innerRadius: number;
×
493
                    if (radius > 0) {
×
494
                        const minRadius = Math.max(2, radius * 0.2);
×
495
                        innerRadius = Math.max(minRadius, radius - innerOffset);
×
496

497
                        if (i === this.options.borders.length - 1) {
×
498
                            const bgRadius = Math.max(minRadius, radius - this.totalBorderWidth);
×
499
                            if (innerRadius < bgRadius + 5) {
×
500
                                innerRadius = bgRadius;
×
501
                            }
×
502
                        }
×
503
                    } else {
×
504
                        innerRadius = 0;
×
505
                    }
×
506

507
                    this._createRoundedRectPath(ctx,
×
508
                        innerOffset,
×
509
                        innerOffset,
×
510
                        width - (innerOffset * 2),
×
511
                        height - (innerOffset * 2),
×
512
                        innerRadius,
×
513
                    );
×
514
                }
×
515

516
                ctx.fill("evenodd");
77✔
517
                ctx.restore();
77✔
518

519
                currentOffset = innerOffset;
77✔
520
            }
77✔
521
        }
77✔
522

523
        ctx.beginPath();
2,085✔
524
        this._createRoundedRectPath(ctx,
2,085✔
525
            this.contentArea.x,
2,085✔
526
            this.contentArea.y,
2,085✔
527
            this.contentArea.width,
2,085✔
528
            this.contentArea.height,
2,085✔
529
            Math.max(0, radius - this.totalBorderWidth),
2,085✔
530
        );
2,085✔
531
        ctx.closePath();
2,085✔
532

533
        this._fillBackground(ctx, this.actualDimensions.width, this.actualDimensions.height);
2,085✔
534

535
        if (this.options._progressBar) {
2,085!
536
            this._drawProgressBar(ctx);
×
537
        }
×
538
    }
2,085✔
539

540
    private _drawBackgroundWithPointer(ctx: CanvasRenderingContext2D): void {
1✔
541
        const {width} = this.actualDimensions;
77✔
542
        const {height} = this.actualDimensions;
77✔
543
        const radius = this.options.cornerRadius;
77✔
544

545
        if (this.options.borders.length > 0 && this.pointerInfo) {
77!
546
            let currentOffset = 0;
×
547

548
            for (let i = 0; i < this.options.borders.length; i++) {
×
549
                const border = this.options.borders[i];
×
550

551
                if (i > 0 && this.options.borders[i - 1].spacing > 0) {
×
552
                    currentOffset += this.options.borders[i - 1].spacing;
×
553
                }
×
554

555
                ctx.save();
×
556
                ctx.fillStyle = border.color;
×
557
                ctx.beginPath();
×
558

559
                const pointerArea: PointerContentArea = {
×
560
                    x: this.contentArea.x - this.totalBorderWidth + currentOffset,
×
561
                    y: this.contentArea.y - this.totalBorderWidth + currentOffset,
×
562
                    width: this.contentArea.width + ((this.totalBorderWidth - currentOffset) * 2),
×
563
                    height: this.contentArea.height + ((this.totalBorderWidth - currentOffset) * 2),
×
564
                };
×
565

566
                this.pointerRenderer.createSpeechBubblePath(ctx, pointerArea, Math.max(0, radius - currentOffset), {
×
567
                    width: this.pointerInfo.width,
×
568
                    height: this.pointerInfo.height,
×
569
                    offset: this.pointerInfo.offset,
×
570
                    direction: this.pointerInfo.direction as "top" | "bottom" | "left" | "right" | "auto",
×
571
                    curved: this.pointerInfo.curve,
×
572
                });
×
573

574
                const innerOffset = currentOffset + border.width;
×
575
                if (i < this.options.borders.length - 1 || innerOffset < this.totalBorderWidth) {
×
576
                    let innerRadius: number;
×
577
                    if (radius > 0) {
×
578
                        const minRadius = Math.max(2, radius * 0.2);
×
579
                        innerRadius = Math.max(minRadius, radius - innerOffset);
×
580

581
                        if (i === this.options.borders.length - 1) {
×
582
                            const bgRadius = Math.max(minRadius, radius - this.totalBorderWidth);
×
583
                            if (innerRadius < bgRadius + 5) {
×
584
                                innerRadius = bgRadius;
×
585
                            }
×
586
                        }
×
587
                    } else {
×
588
                        innerRadius = 0;
×
589
                    }
×
590

591
                    const innerX = this.contentArea.x - this.totalBorderWidth + innerOffset;
×
592
                    const innerY = this.contentArea.y - this.totalBorderWidth + innerOffset;
×
593
                    ctx.moveTo(innerX + innerRadius, innerY);
×
594

595
                    const innerPointerArea: PointerContentArea = {
×
596
                        x: innerX,
×
597
                        y: innerY,
×
598
                        width: this.contentArea.width + ((this.totalBorderWidth - innerOffset) * 2),
×
599
                        height: this.contentArea.height + ((this.totalBorderWidth - innerOffset) * 2),
×
600
                    };
×
601

602
                    this.pointerRenderer.createSpeechBubblePathCCW(ctx, innerPointerArea, innerRadius, {
×
603
                        width: this.pointerInfo.width,
×
604
                        height: this.pointerInfo.height,
×
605
                        offset: this.pointerInfo.offset,
×
606
                        direction: this.pointerInfo.direction as "top" | "bottom" | "left" | "right" | "auto",
×
607
                        curved: this.pointerInfo.curve,
×
608
                    });
×
609
                }
×
610

611
                ctx.fill("evenodd");
×
612
                ctx.restore();
×
613

614
                currentOffset = innerOffset;
×
615
            }
×
616
        }
×
617

618
        ctx.beginPath();
77✔
619
        if (this.pointerInfo) {
77✔
620
            this.pointerRenderer.createSpeechBubblePath(ctx, this.contentArea, radius, {
77✔
621
                width: this.pointerInfo.width,
77✔
622
                height: this.pointerInfo.height,
77✔
623
                offset: this.pointerInfo.offset,
77✔
624
                direction: this.pointerInfo.direction as PointerDirection,
77✔
625
                curved: this.pointerInfo.curve,
77✔
626
            });
77✔
627
        }
77✔
628

629
        ctx.closePath();
77✔
630

631
        this._fillBackground(ctx, width, height);
77✔
632

633
        if (this.options._progressBar) {
77!
634
            this._drawProgressBar(ctx);
×
635
        }
×
636
    }
77✔
637

638
    private _fillBackground(ctx: CanvasRenderingContext2D, width: number, height: number): void {
1✔
639
        if (this.options.backgroundGradient) {
2,162✔
640
            let gradient: CanvasGradient;
77✔
641
            if (this.options.backgroundGradientType === "radial") {
77!
642
                gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) / 2);
×
643
            } else {
77✔
644
                switch (this.options.backgroundGradientDirection) {
77✔
645
                    case "horizontal":
77✔
646
                        gradient = ctx.createLinearGradient(0, 0, width, 0);
77✔
647
                        break;
77✔
648
                    case "diagonal":
77!
649
                        gradient = ctx.createLinearGradient(0, 0, width, height);
×
650
                        break;
×
651
                    case "vertical":
77!
652
                    default:
77!
653
                        gradient = ctx.createLinearGradient(0, 0, 0, height);
×
654
                        break;
×
655
                }
77✔
656
            }
77✔
657

658
            const colors = this.options.backgroundGradientColors;
77✔
659
            for (let i = 0; i < colors.length; i++) {
77✔
660
                gradient.addColorStop(i / (colors.length - 1), colors[i]);
154✔
661
            }
154✔
662
            ctx.fillStyle = gradient;
77✔
663
        } else {
2,162✔
664
            ctx.fillStyle = this.options.backgroundColor;
2,085✔
665
        }
2,085✔
666

667
        ctx.fill();
2,162✔
668
    }
2,162✔
669

670
    private _drawProgressBar(ctx: CanvasRenderingContext2D): void {
1✔
671
        const progressBarHeight = this.contentArea.height * 0.2;
×
672
        const progressBarY = this.contentArea.y + this.contentArea.height - progressBarHeight - this.options.backgroundPadding;
×
673
        const progressBarX = this.contentArea.x + this.options.backgroundPadding;
×
674
        const progressBarWidth = this.contentArea.width - (this.options.backgroundPadding * 2);
×
675

676
        ctx.save();
×
677
        ctx.fillStyle = "rgba(0, 122, 255, 1)";
×
678
        ctx.fillRect(progressBarX, progressBarY, progressBarWidth * this._progressValue, progressBarHeight);
×
679
        ctx.restore();
×
680
    }
×
681

682
    private _createRoundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number,
1✔
683
        width: number, height: number, radius: number): void {
2,162✔
684
        ctx.beginPath();
2,162✔
685
        ctx.moveTo(x + radius, y);
2,162✔
686
        ctx.lineTo(x + width - radius, y);
2,162✔
687
        ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
2,162✔
688
        ctx.lineTo(x + width, y + height - radius);
2,162✔
689
        ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
2,162✔
690
        ctx.lineTo(x + radius, y + height);
2,162✔
691
        ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
2,162✔
692
        ctx.lineTo(x, y + radius);
2,162✔
693
        ctx.quadraticCurveTo(x, y, x + radius, y);
2,162✔
694
        ctx.closePath();
2,162✔
695
    }
2,162✔
696

697
    private _createMaterial(): void {
1✔
698
        this.material = new StandardMaterial(`richTextMaterial_${this.id}`, this.scene);
2,162✔
699
        if (this.texture) {
2,162✔
700
            this.material.diffuseTexture = this.texture;
2,162✔
701
        }
2,162✔
702

703
        this.material.specularColor = new Color3(0, 0, 0);
2,162✔
704
        this.material.emissiveColor = new Color3(1, 1, 1);
2,162✔
705
        this.material.backFaceCulling = false;
2,162✔
706
        this.material.useAlphaFromDiffuseTexture = true;
2,162✔
707
        this.material.alphaMode = Engine.ALPHA_COMBINE;
2,162✔
708
    }
2,162✔
709

710
    private _createMesh(): void {
1✔
711
        const sizeScale = this.options.fontSize / 48;
2,162✔
712

713
        const aspectRatio = this.actualDimensions.width / this.actualDimensions.height;
2,162✔
714
        const planeHeight = sizeScale;
2,162✔
715
        const planeWidth = aspectRatio * sizeScale;
2,162✔
716

717
        this.mesh = MeshBuilder.CreatePlane(`richTextPlane_${this.id}`, {
2,162✔
718
            width: planeWidth,
2,162✔
719
            height: planeHeight,
2,162✔
720
            sideOrientation: Mesh.DOUBLESIDE,
2,162✔
721
        }, this.scene);
2,162✔
722

723
        this.mesh.material = this.material;
2,162✔
724
        this.mesh.billboardMode = this.options.billboardMode;
2,162✔
725
    }
2,162✔
726

727
    private _attachToTarget(): void {
1✔
728
        const target = this.options.attachTo;
2,162✔
729
        const position = this.options.attachPosition;
2,162✔
730
        const offset = this.options.attachOffset;
2,162✔
731

732
        if (!this.mesh) {
2,162!
733
            return;
×
734
        }
×
735

736
        let targetPos: Vector3;
2,162✔
737
        let bounds: {min: Vector3, max: Vector3};
2,162✔
738

739
        if (target instanceof Vector3) {
2,162!
740
            targetPos = target.clone();
×
741
            bounds = {
×
742
                min: targetPos.clone(),
×
743
                max: targetPos.clone(),
×
744
            };
×
745
        } else if (target && "getBoundingInfo" in target && target instanceof AbstractMesh) {
2,162✔
746
            this.mesh.parent = target;
2,162✔
747
            targetPos = Vector3.Zero();
2,162✔
748

749
            const boundingInfo = target.getBoundingInfo();
2,162✔
750
            bounds = {
2,162✔
751
                min: boundingInfo.boundingBox.minimum,
2,162✔
752
                max: boundingInfo.boundingBox.maximum,
2,162✔
753
            };
2,162✔
754
        } else {
2,162!
755
            this.mesh.position = new Vector3(
×
756
                this.options.position.x,
×
757
                this.options.position.y,
×
758
                this.options.position.z,
×
759
            );
×
760
            return;
×
761
        }
×
762

763
        const sizeScale = this.options.fontSize / 48;
2,162✔
764
        const labelWidth = (this.actualDimensions.width / this.actualDimensions.height) * sizeScale;
2,162✔
765
        const labelHeight = sizeScale;
2,162✔
766

767
        const newPos = targetPos.clone();
2,162✔
768

769
        if (this.options.pointer && this.options.pointerDirection === "auto" && this.pointerInfo) {
2,162!
770
            switch (position) {
×
771
                case "top":
×
772
                case "top-left":
×
773
                case "top-right":
×
774
                    this.pointerInfo.direction = "bottom";
×
775
                    break;
×
776
                case "bottom":
×
777
                case "bottom-left":
×
778
                case "bottom-right":
×
779
                    this.pointerInfo.direction = "top";
×
780
                    break;
×
781
                case "left":
×
782
                    this.pointerInfo.direction = "right";
×
783
                    break;
×
784
                case "right":
×
785
                    this.pointerInfo.direction = "left";
×
786
                    break;
×
787
                default:
×
788
                    this.pointerInfo.direction = "bottom";
×
789
            }
×
790

791
            this._calculateDimensions();
×
792
            this._drawContent();
×
793
        }
×
794

795
        switch (position) {
2,162✔
796
            case "top-left":
2,162!
797
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
798
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
799
                break;
×
800
            case "top":
2,162✔
801
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
2,162✔
802
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
2,162✔
803
                break;
2,162✔
804
            case "top-right":
2,162!
805
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
806
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
807
                break;
×
808
            case "left":
2,162!
809
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
810
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
811
                break;
×
812
            case "center":
2,162!
813
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
×
814
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
815
                break;
×
816
            case "right":
2,162!
817
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
818
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
819
                break;
×
820
            case "bottom-left":
2,162!
821
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
822
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
823
                break;
×
824
            case "bottom":
2,162!
825
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
×
826
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
827
                break;
×
828
            case "bottom-right":
2,162!
829
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
830
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
831
                break;
×
832
            default:
2,162!
833
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
834
        }
2,162✔
835

836
        this.mesh.position = newPos;
2,162✔
837
        this.originalPosition ??= newPos.clone();
2,162✔
838

839
        if (this.animator) {
2,162✔
840
            this.animator.updateOriginalPosition(newPos);
154✔
841
        }
154✔
842
    }
2,162✔
843

844
    private _setupDepthFading(): void {
1✔
845
        const camera = this.scene.activeCamera;
77✔
846

847
        this.scene.registerBeforeRender(() => {
77✔
UNCOV
848
            if (!camera || !this.mesh || !this.material) {
×
849
                return;
×
850
            }
×
851

UNCOV
852
            const distance = Vector3.Distance(camera.position, this.mesh.position);
×
853

UNCOV
854
            let fadeFactor = 1.0;
×
UNCOV
855
            if (distance < this.options.depthFadeNear) {
×
UNCOV
856
                fadeFactor = 1.0;
×
UNCOV
857
            } else if (distance > this.options.depthFadeFar) {
×
858
                fadeFactor = 0.0;
×
859
            } else {
×
860
                const fadeRange = this.options.depthFadeFar - this.options.depthFadeNear;
×
861
                fadeFactor = 1.0 - ((distance - this.options.depthFadeNear) / fadeRange);
×
862
            }
×
863

UNCOV
864
            this.material.alpha = fadeFactor;
×
865
        });
77✔
866
    }
77✔
867

868
    public setText(text: string): void {
1✔
869
        if (this.options.smartOverflow && !isNaN(Number(text))) {
×
870
            const num = parseInt(text);
×
871
            if (num > this.options.maxNumber) {
×
872
                if (num >= 1000) {
×
873
                    this.options.text = `${Math.floor(num / 1000)}k`;
×
874
                } else {
×
875
                    this.options.text = `${this.options.maxNumber}${this.options.overflowSuffix}`;
×
876
                }
×
877
            } else {
×
878
                this.options.text = text;
×
879
            }
×
880
        } else {
×
881
            this.options.text = text;
×
882
        }
×
883

884
        this._parseRichText();
×
885
        this._calculateDimensions();
×
886
        this._drawContent();
×
887
    }
×
888

889
    public setProgress(value: number): void {
1✔
890
        this._progressValue = Math.max(0, Math.min(1, value));
×
891
        this._drawContent();
×
892
    }
×
893

894
    public attachTo(target: AbstractMesh | Vector3, position: AttachPosition = "top", offset = 0.5): void {
1✔
895
        this.options.attachTo = target;
×
896
        this.options.attachPosition = position;
×
897
        this.options.attachOffset = offset;
×
898

899
        if (this.mesh?.parent && this.mesh.parent !== target) {
×
900
            this.mesh.parent = null;
×
901
        }
×
902

903
        this._attachToTarget();
×
904
    }
×
905

906
    public dispose(): void {
1✔
907
        if (this.animator) {
×
908
            this.animator.dispose();
×
909
        }
×
910

911
        if (this.mesh) {
×
912
            this.mesh.dispose();
×
913
        }
×
914

915
        if (this.material) {
×
916
            this.material.dispose();
×
917
        }
×
918

919
        if (this.texture) {
×
920
            this.texture.dispose();
×
921
        }
×
922
    }
×
923

924
    public get labelMesh(): Mesh | null {
1✔
925
        return this.mesh;
×
926
    }
×
927

928
    public get labelId(): string {
1✔
929
        return this.id;
×
930
    }
×
931
}
1✔
932

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc