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

graphty-org / graphty-element / 16184952679

10 Jul 2025 02:47AM UTC coverage: 68.949% (-9.6%) from 78.501%
16184952679

push

github

apowers313
feat: implement flexible layout dimension configuratio

  - Add getOptionsForDimension() method to LayoutEngine for customizable dimension handling
  - Remove tempEngine approach in favor of cleaner static method pattern
  - Support arbitrary dimension configuration properties (not just "dim")
  - Fix TypeScript build errors and update type definitions
  - Update CLAUDE.md with ready:commit workflow documentation

479 of 723 branches covered (66.25%)

Branch coverage included in aggregate %.

76 of 97 new or added lines in 22 files covered. (78.35%)

448 existing lines in 6 files now uncovered.

3609 of 5206 relevant lines covered (69.32%)

978.92 hits per line

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

56.0
/src/RichTextLabel.ts
1
import {
1✔
2
    AbstractMesh,
3
    Color3,
1✔
4
    DynamicTexture,
1✔
5
    Engine,
1✔
6
    Mesh,
1✔
7
    MeshBuilder,
1✔
8
    Scene,
9
    StandardMaterial,
1✔
10
    Texture,
1✔
11
    Vector3} from "@babylonjs/core";
1✔
12

13
// Interfaces for type safety
14
interface BorderConfig {
15
    width: number;
16
    color: string;
17
    spacing: number;
18
}
19

20
interface RichTextStyle {
21
    font: string;
22
    size: number;
23
    weight: string;
24
    style: string;
25
    color: string;
26
    background: string | null;
27
}
28

29
interface TextSegment {
30
    text: string;
31
    style: RichTextStyle;
32
}
33

34
interface ContentArea {
35
    x: number;
36
    y: number;
37
    width: number;
38
    height: number;
39
}
40

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

46
interface PointerInfo {
47
    direction: "top" | "bottom" | "left" | "right" | "auto";
48
    width: number;
49
    height: number;
50
    offset: number;
51
    curve: boolean;
52
}
53

54
interface Position3D {
55
    x: number;
56
    y: number;
57
    z: number;
58
}
59

60
type AttachPosition = "top" | "top-left" | "top-right" | "left" | "center" | "right" | "bottom" | "bottom-left" | "bottom-right";
61
type TextAlign = "left" | "center" | "right";
62
type BillboardMode = number; // Babylon's billboard mode constants
63
type AnimationType = "pulse" | "bounce" | "shake" | "glow" | "fill" | null;
64
type BadgeType = "notification" | "label" | "label-success" | "label-warning" | "label-danger" | "count" | "icon" | "progress" | "dot" | undefined;
65
type GradientType = "linear" | "radial";
66
type GradientDirection = "vertical" | "horizontal" | "diagonal";
67

68
export interface RichTextLabelOptions {
69
    // Text content (made optional with default)
70
    text?: string;
71

72
    // Font settings
73
    font?: string;
74
    fontSize?: number;
75
    fontWeight?: string;
76
    lineHeight?: number;
77

78
    // Colors
79
    textColor?: string;
80
    backgroundColor?: string;
81

82
    // Single border (legacy)
83
    borderWidth?: number;
84
    borderColor?: string;
85

86
    // Multiple borders
87
    borders?: BorderConfig[];
88

89
    // Margins
90
    marginTop?: number;
91
    marginBottom?: number;
92
    marginLeft?: number;
93
    marginRight?: number;
94

95
    // Layout
96
    textAlign?: TextAlign;
97
    cornerRadius?: number;
98
    autoSize?: boolean;
99
    resolution?: number;
100
    billboardMode?: BillboardMode;
101

102
    // Position
103
    position?: Position3D;
104
    attachTo?: AbstractMesh | Vector3 | null;
105
    attachPosition?: AttachPosition;
106
    attachOffset?: number;
107

108
    // Depth fading
109
    depthFadeEnabled?: boolean;
110
    depthFadeNear?: number;
111
    depthFadeFar?: number;
112

113
    // Text effects
114
    textOutline?: boolean;
115
    textOutlineWidth?: number;
116
    textOutlineColor?: string;
117
    textOutlineJoin?: CanvasLineJoin;
118
    textShadow?: boolean;
119
    textShadowColor?: string;
120
    textShadowBlur?: number;
121
    textShadowOffsetX?: number;
122
    textShadowOffsetY?: number;
123

124
    // Background effects
125
    backgroundPadding?: number;
126
    backgroundGradient?: boolean;
127
    backgroundGradientType?: GradientType;
128
    backgroundGradientColors?: string[];
129
    backgroundGradientDirection?: GradientDirection;
130

131
    // Pointer/Arrow
132
    pointer?: boolean;
133
    pointerDirection?: "top" | "bottom" | "left" | "right" | "auto";
134
    pointerWidth?: number;
135
    pointerHeight?: number;
136
    pointerOffset?: number;
137
    pointerCurve?: boolean;
138

139
    // Animation
140
    animation?: AnimationType;
141
    animationSpeed?: number;
142

143
    // Badge
144
    badge?: BadgeType;
145
    icon?: string;
146
    iconPosition?: "left" | "right";
147
    progress?: number;
148

149
    // Smart overflow
150
    smartOverflow?: boolean;
151
    maxNumber?: number;
152
    overflowSuffix?: string;
153

154
    // Internal badge properties
155
    _badgeType?: BadgeType;
156
    _smartSizing?: boolean;
157
    _paddingRatio?: number;
158
    _removeText?: boolean;
159
    _progressBar?: boolean;
160
}
161

162
// Type for resolved options where all fields are defined
163
type ResolvedRichTextLabelOptions = Omit<Required<RichTextLabelOptions>, "badge" | "icon" | "progress" | "_badgeType" | "_smartSizing" | "_paddingRatio" | "_removeText" | "_progressBar"> & {
164
    badge: BadgeType;
165
    icon: string | undefined;
166
    progress: number | undefined;
167
    _badgeType: BadgeType;
168
    _smartSizing: boolean | undefined;
169
    _paddingRatio: number | undefined;
170
    _removeText: boolean | undefined;
171
    _progressBar: boolean | undefined;
172
};
173

174
export class RichTextLabel {
1✔
175
    private scene: Scene;
176
    private options: ResolvedRichTextLabelOptions;
177
    private mesh: Mesh | null = null;
1✔
178
    private texture: DynamicTexture | null = null;
1✔
179
    private material: StandardMaterial | null = null;
1✔
180
    private parsedContent: TextSegment[][] = [];
1✔
181
    private actualDimensions: ActualDimensions = {width: 0, height: 0};
1✔
182
    private contentArea: ContentArea = {x: 0, y: 0, width: 0, height: 0};
1✔
183
    private totalBorderWidth = 0;
1✔
184
    private pointerInfo: PointerInfo | null = null;
1✔
185
    private animationTime = 0;
1✔
186
    private _progressValue = 0;
1✔
187
    private id: string;
188
    private originalPosition: Vector3 | null = null;
1✔
189

190
    // Badge style presets
191
    private static readonly BADGE_STYLES: Record<Exclude<BadgeType, undefined>, Partial<RichTextLabelOptions>> = {
1✔
192
        "notification": {
1✔
193
            backgroundColor: "rgba(255, 59, 48, 1)",
1✔
194
            textColor: "white",
1✔
195
            fontWeight: "bold",
1✔
196
            fontSize: 24,
1✔
197
            cornerRadius: 999,
1✔
198
            textAlign: "center",
1✔
199
            smartOverflow: true,
1✔
200
            animation: "pulse",
1✔
201
            textOutline: true,
1✔
202
            textOutlineWidth: 1,
1✔
203
            textOutlineColor: "rgba(0, 0, 0, 0.3)",
1✔
204
            pointer: false,
1✔
205
            _badgeType: "notification",
1✔
206
            _smartSizing: true,
1✔
207
            _paddingRatio: 0.8,
1✔
208
        },
1✔
209
        "label": {
1✔
210
            fontSize: 24,
1✔
211
            cornerRadius: 12,
1✔
212
            fontWeight: "600",
1✔
213
            backgroundColor: "rgba(0, 122, 255, 1)",
1✔
214
            textColor: "white",
1✔
215
            textShadow: true,
1✔
216
            textShadowColor: "rgba(0, 0, 0, 0.3)",
1✔
217
            textShadowBlur: 2,
1✔
218
            textShadowOffsetX: 1,
1✔
219
            textShadowOffsetY: 1,
1✔
220
            _badgeType: "label",
1✔
221
            _paddingRatio: 0.6,
1✔
222
        },
1✔
223
        "label-success": {
1✔
224
            fontSize: 24,
1✔
225
            cornerRadius: 12,
1✔
226
            fontWeight: "600",
1✔
227
            backgroundColor: "rgba(52, 199, 89, 1)",
1✔
228
            textColor: "white",
1✔
229
            textShadow: true,
1✔
230
            textShadowColor: "rgba(0, 0, 0, 0.3)",
1✔
231
            _badgeType: "label",
1✔
232
            _paddingRatio: 0.6,
1✔
233
        },
1✔
234
        "label-warning": {
1✔
235
            fontSize: 24,
1✔
236
            cornerRadius: 12,
1✔
237
            fontWeight: "600",
1✔
238
            backgroundColor: "rgba(255, 204, 0, 1)",
1✔
239
            textColor: "black",
1✔
240
            textOutline: true,
1✔
241
            textOutlineWidth: 1,
1✔
242
            textOutlineColor: "rgba(255, 255, 255, 0.5)",
1✔
243
            _badgeType: "label",
1✔
244
            _paddingRatio: 0.6,
1✔
245
        },
1✔
246
        "label-danger": {
1✔
247
            fontSize: 24,
1✔
248
            cornerRadius: 12,
1✔
249
            fontWeight: "600",
1✔
250
            backgroundColor: "rgba(255, 59, 48, 1)",
1✔
251
            textColor: "white",
1✔
252
            textShadow: true,
1✔
253
            textShadowColor: "rgba(0, 0, 0, 0.3)",
1✔
254
            _badgeType: "label",
1✔
255
            _paddingRatio: 0.6,
1✔
256
        },
1✔
257
        "count": {
1✔
258
            backgroundColor: "rgba(0, 122, 255, 1)",
1✔
259
            textColor: "white",
1✔
260
            fontWeight: "bold",
1✔
261
            fontSize: 22,
1✔
262
            cornerRadius: 999,
1✔
263
            textAlign: "center",
1✔
264
            smartOverflow: true,
1✔
265
            textOutline: true,
1✔
266
            textOutlineWidth: 1,
1✔
267
            textOutlineColor: "rgba(0, 0, 0, 0.2)",
1✔
268
            _badgeType: "count",
1✔
269
            _smartSizing: true,
1✔
270
            _paddingRatio: 0.7,
1✔
271
        },
1✔
272
        "icon": {
1✔
273
            fontSize: 28,
1✔
274
            cornerRadius: 999,
1✔
275
            textAlign: "center",
1✔
276
            backgroundColor: "rgba(100, 100, 100, 0.8)",
1✔
277
            textShadow: true,
1✔
278
            _badgeType: "icon",
1✔
279
            _paddingRatio: 0.5,
1✔
280
        },
1✔
281
        "progress": {
1✔
282
            backgroundColor: "rgba(235, 235, 235, 1)",
1✔
283
            textColor: "black",
1✔
284
            fontSize: 24,
1✔
285
            cornerRadius: 12,
1✔
286
            fontWeight: "600",
1✔
287
            animation: "fill",
1✔
288
            textOutline: true,
1✔
289
            textOutlineWidth: 1,
1✔
290
            textOutlineColor: "white",
1✔
291
            _badgeType: "progress",
1✔
292
            _paddingRatio: 0.8,
1✔
293
            _progressBar: true,
1✔
294
        },
1✔
295
        "dot": {
1✔
296
            backgroundColor: "rgba(255, 59, 48, 1)",
1✔
297
            cornerRadius: 999,
1✔
298
            animation: "pulse",
1✔
299
            pointer: false,
1✔
300
            _badgeType: "dot",
1✔
301
            _removeText: true,
1✔
302
            marginTop: 6,
1✔
303
            marginBottom: 6,
1✔
304
            marginLeft: 6,
1✔
305
            marginRight: 6,
1✔
306
            fontSize: 8,
1✔
307
        },
1✔
308
    };
1✔
309

310
    constructor(scene: Scene, options: RichTextLabelOptions = {}) {
1✔
311
        this.scene = scene;
2,008✔
312

313
        // Validate fontSize to prevent crashes with large values
314
        if (options.fontSize !== undefined && options.fontSize > 500) {
2,008!
315
            console.warn(`RichTextLabel: fontSize ${options.fontSize} exceeds maximum of 500, clamping to 500`);
×
316
            options.fontSize = 500;
×
317
        }
×
318

319
        // Default options - using a custom type that allows undefined for optional badge-related fields
320
        const defaultOptions: ResolvedRichTextLabelOptions = {
2,008✔
321
            text: "Label",
2,008✔
322
            font: "Verdana",
2,008✔
323
            fontSize: 48,
2,008✔
324
            fontWeight: "normal",
2,008✔
325
            lineHeight: 1.2,
2,008✔
326
            textColor: "black",
2,008✔
327
            backgroundColor: "transparent",
2,008✔
328
            borderWidth: 0,
2,008✔
329
            borderColor: "rgba(255, 255, 255, 0.8)",
2,008✔
330
            borders: [],
2,008✔
331
            marginTop: 5,
2,008✔
332
            marginBottom: 5,
2,008✔
333
            marginLeft: 5,
2,008✔
334
            marginRight: 5,
2,008✔
335
            textAlign: "center",
2,008✔
336
            cornerRadius: 0,
2,008✔
337
            autoSize: true,
2,008✔
338
            resolution: 1024,
2,008✔
339
            billboardMode: Mesh.BILLBOARDMODE_ALL,
2,008✔
340
            depthFadeEnabled: false,
2,008✔
341
            depthFadeNear: 5,
2,008✔
342
            depthFadeFar: 20,
2,008✔
343
            position: {x: 0, y: 0, z: 0},
2,008✔
344
            attachTo: null,
2,008✔
345
            attachPosition: "top",
2,008✔
346
            attachOffset: 0.5,
2,008✔
347
            textOutline: false,
2,008✔
348
            textOutlineWidth: 2,
2,008✔
349
            textOutlineColor: "black",
2,008✔
350
            textOutlineJoin: "round",
2,008✔
351
            textShadow: false,
2,008✔
352
            textShadowColor: "rgba(0, 0, 0, 0.5)",
2,008✔
353
            textShadowBlur: 4,
2,008✔
354
            textShadowOffsetX: 2,
2,008✔
355
            textShadowOffsetY: 2,
2,008✔
356
            backgroundPadding: 0,
2,008✔
357
            backgroundGradient: false,
2,008✔
358
            backgroundGradientType: "linear",
2,008✔
359
            backgroundGradientColors: ["rgba(0, 0, 0, 0.8)", "rgba(50, 50, 50, 0.8)"],
2,008✔
360
            backgroundGradientDirection: "vertical",
2,008✔
361
            pointer: false,
2,008✔
362
            pointerDirection: "bottom",
2,008✔
363
            pointerWidth: 20,
2,008✔
364
            pointerHeight: 15,
2,008✔
365
            pointerOffset: 0,
2,008✔
366
            pointerCurve: true,
2,008✔
367
            animation: null,
2,008✔
368
            animationSpeed: 1,
2,008✔
369
            badge: undefined,
2,008✔
370
            icon: undefined,
2,008✔
371
            iconPosition: "left",
2,008✔
372
            progress: undefined,
2,008✔
373
            smartOverflow: false,
2,008✔
374
            maxNumber: 999,
2,008✔
375
            overflowSuffix: "+",
2,008✔
376
            _badgeType: undefined,
2,008✔
377
            _smartSizing: undefined,
2,008✔
378
            _paddingRatio: undefined,
2,008✔
379
            _removeText: undefined,
2,008✔
380
            _progressBar: undefined,
2,008✔
381
        };
2,008✔
382

383
        // Start with defaults
384
        let finalOptions = Object.assign({}, defaultOptions);
2,008✔
385

386
        // Apply badge preset if specified
387
        if (options.badge !== undefined) {
2,008✔
388
            const badgePreset = RichTextLabel.BADGE_STYLES[options.badge];
77✔
389
            finalOptions = Object.assign(finalOptions, badgePreset);
77✔
390

391
            // Apply smart badge behaviors
392
            this._applyBadgeBehaviors(finalOptions, options);
77✔
393
        }
77✔
394

395
        // Apply user options (these override everything)
396
        finalOptions = Object.assign(finalOptions, options) as ResolvedRichTextLabelOptions;
2,008✔
397

398
        // Apply smart overflow if enabled (regardless of badge type)
399
        if (finalOptions.smartOverflow && finalOptions.text && !isNaN(Number(finalOptions.text))) {
2,008✔
400
            const num = parseInt(finalOptions.text);
231✔
401
            if (num > finalOptions.maxNumber) {
231✔
402
                if (num >= 1000) {
231✔
403
                    finalOptions.text = `${Math.floor(num / 1000)}k`;
154✔
404
                } else {
231✔
405
                    finalOptions.text = `${finalOptions.maxNumber}${finalOptions.overflowSuffix}`;
77✔
406
                }
77✔
407
            }
231✔
408
        }
231✔
409

410
        // Convert single border to borders array if needed
411
        if (finalOptions.borderWidth > 0 && finalOptions.borders.length === 0) {
2,008✔
412
            finalOptions.borders = [{
77✔
413
                width: finalOptions.borderWidth,
77✔
414
                color: finalOptions.borderColor,
77✔
415
                spacing: 0,
77✔
416
            }];
77✔
417
        }
77✔
418

419
        // Store the final options
420
        this.options = finalOptions;
2,008✔
421

422
        // Generate unique ID
423
        this.id = `richLabel_${Math.random().toString(36).substring(2, 11)}`;
2,008✔
424

425
        this._create();
2,008✔
426
    }
2,008✔
427

428
    private _applyBadgeBehaviors(options: ResolvedRichTextLabelOptions, userOptions: RichTextLabelOptions): void {
1✔
429
        const badgeType = options._badgeType;
77✔
430

431
        // Handle padding ratio
432
        if (options._paddingRatio && !userOptions.marginTop) {
77✔
433
            const padding = options.fontSize * options._paddingRatio;
77✔
434
            options.marginTop = options.marginBottom = padding;
77✔
435
            options.marginLeft = options.marginRight = padding;
77✔
436
        }
77✔
437

438
        // Badge-specific behaviors
439
        switch (badgeType) {
77✔
440
            case "notification":
77✔
441
            case "count": {
77✔
442
                // Apply smart overflow
443
                if (options.smartOverflow && !isNaN(Number(userOptions.text))) {
77!
444
                    const num = parseInt(userOptions.text ?? "0");
×
445
                    if (num > options.maxNumber) {
×
446
                        if (num >= 1000) {
×
447
                            options.text = `${Math.floor(num / 1000)}k`;
×
448
                        } else {
×
449
                            options.text = `${options.maxNumber}${options.overflowSuffix}`;
×
450
                        }
×
451
                    }
×
452
                }
×
453

454
                break;
77✔
455
            }
77✔
456
            case "dot": {
77!
457
                if (options._removeText) {
×
458
                    options.text = "";
×
459
                }
×
460

461
                break;
×
462
            }
×
463
            case "progress": {
77!
464
                if (userOptions.progress !== undefined) {
×
465
                    this._progressValue = Math.max(0, Math.min(1, userOptions.progress));
×
466
                }
×
467

468
                break;
×
469
            }
×
470
            case "icon": {
77!
471
                if (userOptions.icon && !userOptions.text) {
×
472
                    options.text = userOptions.icon;
×
473
                } else if (userOptions.icon && userOptions.text) {
×
474
                    const iconPos = userOptions.iconPosition ?? "left";
×
475
                    if (iconPos === "left") {
×
476
                        options.text = `${userOptions.icon} ${userOptions.text}`;
×
477
                    } else {
×
478
                        options.text = `${userOptions.text} ${userOptions.icon}`;
×
479
                    }
×
480
                }
×
481

482
                break;
×
483
            }
×
484
            default:
77!
485
                // No special behavior needed
486
                break;
×
487
        }
77✔
488
    }
77✔
489

490
    private _create(): void {
1✔
491
        this._parseRichText();
2,008✔
492
        this._calculateDimensions();
2,008✔
493
        this._createTexture();
2,008✔
494
        this._createMaterial();
2,008✔
495
        this._createMesh();
2,008✔
496

497
        if (this.options.attachTo) {
2,008✔
498
            this._attachToTarget();
2,008✔
499
        } else if (this.mesh) {
2,008!
500
            this.mesh.position = new Vector3(
×
501
                this.options.position.x,
×
502
                this.options.position.y,
×
503
                this.options.position.z,
×
504
            );
×
505
            // Store original position for animations
506
            this.originalPosition ??= this.mesh.position.clone();
×
507
        }
×
508

509
        if (this.options.depthFadeEnabled) {
2,008✔
510
            this._setupDepthFading();
77✔
511
        }
77✔
512

513
        if (this.options.animation) {
2,008✔
514
            this._setupAnimation();
154✔
515
        }
154✔
516
    }
2,008✔
517

518
    private _parseRichText(): void {
1✔
519
        const {text} = this.options;
2,008✔
520
        const lines = text.split("\n");
2,008✔
521
        this.parsedContent = [];
2,008✔
522

523
        for (const line of lines) {
2,008✔
524
            const segments: TextSegment[] = [];
2,393✔
525
            let currentPos = 0;
2,393✔
526

527
            const styleStack: RichTextStyle[] = [{
2,393✔
528
                font: this.options.font,
2,393✔
529
                size: this.options.fontSize,
2,393✔
530
                weight: this.options.fontWeight,
2,393✔
531
                style: "normal",
2,393✔
532
                color: this.options.textColor,
2,393✔
533
                background: null,
2,393✔
534
            }];
2,393✔
535

536
            const tagRegex = /<(\/?)(bold|italic|color|size|font|bg)(?:='([^']*)')?>/g;
2,393✔
537
            let match;
2,393✔
538

539
            while ((match = tagRegex.exec(line)) !== null) {
2,393!
540
                if (match.index > currentPos) {
×
541
                    segments.push({
×
542
                        text: line.substring(currentPos, match.index),
×
543
                        style: Object.assign({}, styleStack[styleStack.length - 1]),
×
544
                    });
×
545
                }
×
546

547
                const isClosing = match[1] === "/";
×
548
                const tagName = match[2];
×
549
                const tagValue = match[3];
×
550

551
                if (isClosing) {
×
552
                    if (styleStack.length > 1) {
×
553
                        styleStack.pop();
×
554
                    }
×
555
                } else {
×
556
                    const newStyle = Object.assign({}, styleStack[styleStack.length - 1]);
×
557

558
                    switch (tagName) {
×
559
                        case "bold":
×
560
                            newStyle.weight = "bold";
×
561
                            break;
×
562
                        case "italic":
×
563
                            newStyle.style = "italic";
×
564
                            break;
×
565
                        case "color":
×
566
                            newStyle.color = tagValue || this.options.textColor;
×
567
                            break;
×
568
                        case "size":
×
569
                            newStyle.size = parseInt(tagValue || "0") || this.options.fontSize;
×
570
                            break;
×
571
                        case "font":
×
572
                            newStyle.font = tagValue || this.options.font;
×
573
                            break;
×
574
                        case "bg":
×
575
                            newStyle.background = tagValue || null;
×
576
                            break;
×
577
                        default:
×
578
                            // Unknown tag, ignore
579
                            break;
×
580
                    }
×
581

582
                    styleStack.push(newStyle);
×
583
                }
×
584

585
                currentPos = match.index + match[0].length;
×
586
            }
×
587

588
            if (currentPos < line.length) {
2,393✔
589
                segments.push({
2,393✔
590
                    text: line.substring(currentPos),
2,393✔
591
                    style: Object.assign({}, styleStack[styleStack.length - 1]),
2,393✔
592
                });
2,393✔
593
            }
2,393✔
594

595
            this.parsedContent.push(segments);
2,393✔
596
        }
2,393✔
597
    }
2,008✔
598

599
    private _calculateDimensions(): void {
1✔
600
        const tempCanvas = document.createElement("canvas");
2,008✔
601
        const tempCtx = tempCanvas.getContext("2d");
2,008✔
602
        if (!tempCtx) {
2,008!
603
            return;
×
604
        }
×
605

606
        let maxWidth = 0;
2,008✔
607
        let totalHeight = 0;
2,008✔
608

609
        for (const lineSegments of this.parsedContent) {
2,008✔
610
            let lineWidth = 0;
2,393✔
611
            let maxLineHeight = 0;
2,393✔
612

613
            for (const segment of lineSegments) {
2,393✔
614
                const {style} = segment;
2,393✔
615

616
                tempCtx.font = `${style.style} ${style.weight} ${style.size}px ${style.font}`;
2,393✔
617
                const metrics = tempCtx.measureText(segment.text);
2,393✔
618

619
                lineWidth += metrics.width;
2,393✔
620
                maxLineHeight = Math.max(maxLineHeight, style.size);
2,393✔
621
            }
2,393✔
622

623
            // Add extra width for outline if enabled
624
            if (this.options.textOutline) {
2,393✔
625
                lineWidth += this.options.textOutlineWidth * 2;
154✔
626
                maxLineHeight += this.options.textOutlineWidth * 2;
154✔
627
            }
154✔
628

629
            maxWidth = Math.max(maxWidth, lineWidth);
2,393✔
630
            totalHeight += maxLineHeight * this.options.lineHeight;
2,393✔
631
        }
2,393✔
632

633
        // Add background padding
634
        const bgPadding = this.options.backgroundPadding * 2;
2,008✔
635

636
        // Calculate total border width INCLUDING spacing between borders
637
        this.totalBorderWidth = 0;
2,008✔
638
        if (this.options.borders.length > 0) {
2,008✔
639
            for (let i = 0; i < this.options.borders.length; i++) {
77✔
640
                this.totalBorderWidth += this.options.borders[i].width;
77✔
641
                // Add spacing AFTER each border except the last
642
                if (i < this.options.borders.length - 1 && this.options.borders[i].spacing > 0) {
77!
643
                    this.totalBorderWidth += this.options.borders[i].spacing;
×
644
                }
×
645
            }
77✔
646
        }
77✔
647

648
        // Calculate content dimensions (text + margins + padding)
649
        const contentWidth = maxWidth + this.options.marginLeft + this.options.marginRight + bgPadding;
2,008✔
650
        const contentHeight = totalHeight + this.options.marginTop + this.options.marginBottom + bgPadding;
2,008✔
651

652
        // Initialize content area (will be adjusted for pointer and borders)
653
        this.contentArea = {
2,008✔
654
            x: this.totalBorderWidth,
2,008✔
655
            y: this.totalBorderWidth,
2,008✔
656
            width: contentWidth,
2,008✔
657
            height: contentHeight,
2,008✔
658
        };
2,008✔
659

660
        // Set initial dimensions
661
        this.actualDimensions.width = contentWidth + (this.totalBorderWidth * 2);
2,008✔
662
        this.actualDimensions.height = contentHeight + (this.totalBorderWidth * 2);
2,008✔
663

664
        // Add space for pointer if enabled
665
        if (this.options.pointer) {
2,008✔
666
            this._calculatePointerDimensions();
77✔
667

668
            // Adjust dimensions and content area based on pointer direction
669
            if (this.pointerInfo) {
77✔
670
                switch (this.pointerInfo.direction) {
77✔
671
                    case "top":
77!
672
                        this.actualDimensions.height += this.options.pointerHeight;
×
673
                        this.contentArea.y = this.totalBorderWidth + this.options.pointerHeight;
×
674
                        break;
×
675
                    case "bottom":
77✔
676
                        this.actualDimensions.height += this.options.pointerHeight;
77✔
677
                        // Content area stays at y = totalBorderWidth
678
                        break;
77✔
679
                    case "left":
77!
680
                        this.actualDimensions.width += this.options.pointerHeight;
×
681
                        this.contentArea.x = this.totalBorderWidth + this.options.pointerHeight;
×
682
                        break;
×
683
                    case "right":
77!
684
                        this.actualDimensions.width += this.options.pointerHeight;
×
685
                        // Content area stays at x = totalBorderWidth
686
                        break;
×
687
                    default:
77!
688
                        // Auto or unknown direction
689
                        break;
×
690
                }
77✔
691
            }
77✔
692
        }
77✔
693

694
        // Apply smart sizing for badges
695
        if (this.options._smartSizing) {
2,008✔
696
            const minDimension = this.actualDimensions.height;
77✔
697
            if (this.actualDimensions.width < minDimension) {
77!
698
                const extraWidth = minDimension - this.actualDimensions.width;
×
699
                this.actualDimensions.width = minDimension;
×
700
                this.contentArea.x += extraWidth / 2;
×
701
            }
×
702
        }
77✔
703
    }
2,008✔
704

705
    private _calculatePointerDimensions(): void {
1✔
706
        let direction = this.options.pointerDirection;
77✔
707

708
        // Auto-calculate direction if attached
709
        if (direction === "auto" && this.options.attachTo) {
77!
710
            // This will be calculated during attachment
711
            direction = "bottom"; // Default for now
×
712
        }
×
713

714
        this.pointerInfo = {
77✔
715
            direction: direction,
77✔
716
            width: this.options.pointerWidth,
77✔
717
            height: this.options.pointerHeight,
77✔
718
            offset: this.options.pointerOffset,
77✔
719
            curve: this.options.pointerCurve,
77✔
720
        };
77✔
721
    }
77✔
722

723
    private _createTexture(): void {
1✔
724
        // Maximum texture size to prevent GPU memory issues
725
        const MAX_TEXTURE_SIZE = 4096;
2,008✔
726

727
        let textureWidth = this.options.autoSize ?
2,008✔
728
            Math.pow(2, Math.ceil(Math.log2(this.actualDimensions.width))) :
2,008!
729
            this.options.resolution;
×
730

731
        const aspectRatio = this.actualDimensions.width / this.actualDimensions.height;
2,008✔
732
        let textureHeight = this.options.autoSize ?
2,008✔
733
            Math.pow(2, Math.ceil(Math.log2(this.actualDimensions.height))) :
2,008!
734
            Math.floor(textureWidth / aspectRatio);
×
735

736
        // Clamp texture dimensions to prevent crashes
737
        if (textureWidth > MAX_TEXTURE_SIZE || textureHeight > MAX_TEXTURE_SIZE) {
2,008!
738
            const scale = MAX_TEXTURE_SIZE / Math.max(textureWidth, textureHeight);
×
739
            textureWidth = Math.floor(textureWidth * scale);
×
740
            textureHeight = Math.floor(textureHeight * scale);
×
741
            console.warn(`RichTextLabel: Texture size clamped to ${textureWidth}x${textureHeight} (max: ${MAX_TEXTURE_SIZE})`);
×
742
        }
×
743

744
        this.texture = new DynamicTexture(`richTextTexture_${this.id}`, {
2,008✔
745
            width: textureWidth,
2,008✔
746
            height: textureHeight,
2,008✔
747
        }, this.scene, true);
2,008✔
748

749
        this.texture.hasAlpha = true;
2,008✔
750
        this.texture.updateSamplingMode(Texture.TRILINEAR_SAMPLINGMODE);
2,008✔
751

752
        this._drawContent();
2,008✔
753
    }
2,008✔
754

755
    private _drawContent(): void {
1✔
756
        if (!this.texture) {
2,008!
757
            return;
×
758
        }
×
759

760
        const ctx = this.texture.getContext() as unknown as CanvasRenderingContext2D;
2,008✔
761
        const {width} = this.texture.getSize();
2,008✔
762
        const {height} = this.texture.getSize();
2,008✔
763

764
        ctx.clearRect(0, 0, width, height);
2,008✔
765

766
        const scaleX = width / this.actualDimensions.width;
2,008✔
767
        const scaleY = height / this.actualDimensions.height;
2,008✔
768
        ctx.save();
2,008✔
769
        ctx.scale(scaleX, scaleY);
2,008✔
770

771
        // Draw background with pointer
772
        if (this.options.pointer) {
2,008✔
773
            this._drawBackgroundWithPointer(ctx);
77✔
774
        } else {
2,008✔
775
            // Draw background with multiple borders
776
            this._drawBackgroundWithBorders(ctx);
1,931✔
777
        }
1,931✔
778

779
        // Draw rich text with effects
780
        this._drawRichText(ctx);
2,008✔
781

782
        ctx.restore();
2,008✔
783
        this.texture.update();
2,008✔
784
    }
2,008✔
785

786
    private _drawBackgroundWithBorders(ctx: CanvasRenderingContext2D): void {
1✔
787
        const {width} = this.actualDimensions;
1,931✔
788
        const {height} = this.actualDimensions;
1,931✔
789
        const radius = this.options.cornerRadius;
1,931✔
790

791
        // Smart Radius Calculation:
792
        // To prevent inner borders from having square corners that extend beyond
793
        // the rounded background, we ensure a minimum radius and smooth transitions.
794
        // The minimum radius is either 2px or 20% of the original radius.
795
        // For the innermost border, we match it closely to the background radius.
796

797
        // Draw borders from outside to inside as filled rings
798
        if (this.options.borders.length > 0) {
1,931✔
799
            let currentOffset = 0;
77✔
800

801
            for (let i = 0; i < this.options.borders.length; i++) {
77✔
802
                const border = this.options.borders[i];
77✔
803

804
                // Add spacing from previous border
805
                if (i > 0 && this.options.borders[i - 1].spacing > 0) {
77!
806
                    currentOffset += this.options.borders[i - 1].spacing;
×
807
                }
×
808

809
                // Calculate outer boundary
810
                const outerX = currentOffset;
77✔
811
                const outerY = currentOffset;
77✔
812
                const outerW = width - (currentOffset * 2);
77✔
813
                const outerH = height - (currentOffset * 2);
77✔
814
                const outerRadius = Math.max(0, radius - currentOffset);
77✔
815

816
                // Calculate inner boundary
817
                const innerOffset = currentOffset + border.width;
77✔
818
                const innerX = innerOffset;
77✔
819
                const innerY = innerOffset;
77✔
820
                const innerW = width - (innerOffset * 2);
77✔
821
                const innerH = height - (innerOffset * 2);
77✔
822

823
                // Smart radius calculation to prevent square corners
824
                let innerRadius: number;
77✔
825
                if (radius > 0) {
77!
826
                    // Ensure minimum radius of 2px or 20% of original radius, whichever is larger
827
                    const minRadius = Math.max(2, radius * 0.2);
×
828
                    innerRadius = Math.max(minRadius, radius - innerOffset);
×
829

830
                    // If this is getting close to the background, match its radius
831
                    if (i === this.options.borders.length - 1) {
×
832
                        const bgRadius = Math.max(0, radius - this.totalBorderWidth);
×
833
                        if (innerRadius < bgRadius + 5) {
×
834
                            innerRadius = bgRadius;
×
835
                        }
×
836
                    }
×
837
                } else {
77✔
838
                    innerRadius = 0;
77✔
839
                }
77✔
840

841
                // Draw border as a filled ring
842
                ctx.fillStyle = border.color;
77✔
843
                ctx.beginPath();
77✔
844

845
                // Outer path (clockwise)
846
                this._createRoundedRectPath(ctx, outerX, outerY, outerW, outerH, outerRadius);
77✔
847

848
                // Only draw inner path if this isn't the innermost border
849
                if (i < this.options.borders.length - 1 || innerOffset < this.totalBorderWidth) {
77!
850
                    // Inner path (counter-clockwise for hole)
851
                    ctx.moveTo(innerX + innerRadius, innerY);
×
852
                    ctx.arcTo(innerX + innerW, innerY, innerX + innerW, innerY + innerRadius, innerRadius);
×
853
                    ctx.lineTo(innerX + innerW, innerY + innerH - innerRadius);
×
854
                    ctx.arcTo(innerX + innerW, innerY + innerH, innerX + innerW - innerRadius, innerY + innerH, innerRadius);
×
855
                    ctx.lineTo(innerX + innerRadius, innerY + innerH);
×
856
                    ctx.arcTo(innerX, innerY + innerH, innerX, innerY + innerH - innerRadius, innerRadius);
×
857
                    ctx.lineTo(innerX, innerY + innerRadius);
×
858
                    ctx.arcTo(innerX, innerY, innerX + innerRadius, innerY, innerRadius);
×
859
                    ctx.closePath();
×
860
                }
×
861

862
                ctx.fill("evenodd");
77✔
863

864
                // Update offset for next border
865
                currentOffset = innerOffset;
77✔
866
            }
77✔
867
        }
77✔
868

869
        // Draw background fill (no gap!)
870
        const fillX = this.totalBorderWidth;
1,931✔
871
        const fillY = this.totalBorderWidth;
1,931✔
872
        const fillW = width - (this.totalBorderWidth * 2);
1,931✔
873
        const fillH = height - (this.totalBorderWidth * 2);
1,931✔
874

875
        // Smart radius for background fill
876
        let fillRadius: number;
1,931✔
877
        if (radius > 0) {
1,931✔
878
            // Ensure minimum radius that looks good
879
            const minRadius = Math.max(2, radius * 0.2);
154✔
880
            fillRadius = Math.max(minRadius, radius - this.totalBorderWidth);
154✔
881
        } else {
1,931✔
882
            fillRadius = 0;
1,777✔
883
        }
1,777✔
884

885
        ctx.beginPath();
1,931✔
886
        this._createRoundedRectPath(ctx, fillX, fillY, fillW, fillH, fillRadius);
1,931✔
887

888
        // Fill with gradient or solid color
889
        if (this.options.backgroundGradient) {
1,931✔
890
            let gradient: CanvasGradient;
77✔
891
            if (this.options.backgroundGradientType === "radial") {
77!
892
                gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) / 2);
×
893
            } else {
77✔
894
                switch (this.options.backgroundGradientDirection) {
77✔
895
                    case "horizontal":
77✔
896
                        gradient = ctx.createLinearGradient(0, 0, width, 0);
77✔
897
                        break;
77✔
898
                    case "diagonal":
77!
899
                        gradient = ctx.createLinearGradient(0, 0, width, height);
×
900
                        break;
×
901
                    case "vertical":
77!
902
                    default:
77!
903
                        gradient = ctx.createLinearGradient(0, 0, 0, height);
×
904
                        break;
×
905
                }
77✔
906
            }
77✔
907

908
            const colors = this.options.backgroundGradientColors;
77✔
909
            for (let i = 0; i < colors.length; i++) {
77✔
910
                gradient.addColorStop(i / (colors.length - 1), colors[i]);
154✔
911
            }
154✔
912
            ctx.fillStyle = gradient;
77✔
913
        } else {
1,931✔
914
            ctx.fillStyle = this.options.backgroundColor;
1,854✔
915
        }
1,854✔
916

917
        ctx.fill();
1,931✔
918
    }
1,931✔
919

920
    private _drawBackgroundWithPointer(ctx: CanvasRenderingContext2D): void {
1✔
921
        const {width} = this.actualDimensions;
77✔
922
        const {height} = this.actualDimensions;
77✔
923
        const radius = this.options.cornerRadius;
77✔
924
        const {pointerHeight} = this.options;
77✔
925
        const {pointerWidth} = this.options;
77✔
926
        const {pointerOffset} = this.options;
77✔
927

928
        // Use pre-calculated content area
929
        const contentX = this.contentArea.x;
77✔
930
        const contentY = this.contentArea.y;
77✔
931
        const contentWidth = this.contentArea.width;
77✔
932
        const contentHeight = this.contentArea.height;
77✔
933

934
        // Draw multiple borders if enabled
935
        if (this.options.borders.length > 0) {
77!
936
            let currentOffset = 0;
×
937

938
            for (let i = 0; i < this.options.borders.length; i++) {
×
939
                const border = this.options.borders[i];
×
940

941
                // Add spacing from previous border
942
                if (i > 0 && this.options.borders[i - 1].spacing > 0) {
×
943
                    currentOffset += this.options.borders[i - 1].spacing;
×
944
                }
×
945

946
                ctx.save();
×
947
                ctx.fillStyle = border.color;
×
948
                ctx.beginPath();
×
949

950
                // Create outer speech bubble path
951
                if (this.pointerInfo) {
×
952
                    this._createSpeechBubblePath(ctx,
×
953
                        contentX - this.totalBorderWidth + currentOffset,
×
954
                        contentY - this.totalBorderWidth + currentOffset,
×
955
                        contentWidth + ((this.totalBorderWidth - currentOffset) * 2),
×
956
                        contentHeight + ((this.totalBorderWidth - currentOffset) * 2),
×
957
                        Math.max(0, radius - currentOffset),
×
958
                        pointerWidth,
×
959
                        pointerHeight,
×
960
                        pointerOffset,
×
961
                        this.pointerInfo.direction,
×
962
                        this.options.pointerCurve,
×
963
                    );
×
964
                }
×
965

966
                // Create inner speech bubble path (for the hole) if not the last/innermost border
967
                const innerOffset = currentOffset + border.width;
×
968
                if (i < this.options.borders.length - 1 || innerOffset < this.totalBorderWidth) {
×
969
                    // Smart radius calculation for inner path
970
                    let innerRadius: number;
×
971
                    if (radius > 0) {
×
972
                        const minRadius = Math.max(2, radius * 0.2);
×
973
                        innerRadius = Math.max(minRadius, radius - innerOffset);
×
974

975
                        // If this is getting close to the background, match its radius
976
                        if (i === this.options.borders.length - 1) {
×
977
                            const bgRadius = Math.max(minRadius, radius - this.totalBorderWidth);
×
978
                            if (innerRadius < bgRadius + 5) {
×
979
                                innerRadius = bgRadius;
×
980
                            }
×
981
                        }
×
982
                    } else {
×
983
                        innerRadius = 0;
×
984
                    }
×
985

986
                    // Move to start position for inner path
987
                    const innerX = contentX - this.totalBorderWidth + innerOffset;
×
988
                    const innerY = contentY - this.totalBorderWidth + innerOffset;
×
989
                    ctx.moveTo(innerX + innerRadius, innerY);
×
990

991
                    // Draw inner path counter-clockwise
992
                    if (this.pointerInfo) {
×
993
                        this._createSpeechBubblePathCCW(ctx,
×
994
                            innerX,
×
995
                            innerY,
×
996
                            contentWidth + ((this.totalBorderWidth - innerOffset) * 2),
×
997
                            contentHeight + ((this.totalBorderWidth - innerOffset) * 2),
×
998
                            innerRadius,
×
999
                            pointerWidth,
×
1000
                            pointerHeight,
×
1001
                            pointerOffset,
×
1002
                            this.pointerInfo.direction,
×
1003
                            this.options.pointerCurve,
×
1004
                        );
×
1005
                    }
×
1006
                }
×
1007

1008
                ctx.fill("evenodd");
×
1009
                ctx.restore();
×
1010

1011
                currentOffset = innerOffset;
×
1012
            }
×
1013
        }
×
1014

1015
        // Create main speech bubble path for background fill
1016
        ctx.beginPath();
77✔
1017
        if (this.pointerInfo) {
77✔
1018
            this._createSpeechBubblePath(ctx, contentX, contentY, contentWidth, contentHeight, radius, pointerWidth, pointerHeight, pointerOffset, this.pointerInfo.direction, this.options.pointerCurve);
77✔
1019
        }
77✔
1020

1021
        ctx.closePath();
77✔
1022

1023
        // Fill background
1024
        if (this.options.backgroundGradient) {
77!
1025
            let gradient: CanvasGradient;
×
1026
            if (this.options.backgroundGradientType === "radial") {
×
1027
                gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) / 2);
×
1028
            } else {
×
1029
                switch (this.options.backgroundGradientDirection) {
×
1030
                    case "horizontal":
×
1031
                        gradient = ctx.createLinearGradient(0, 0, width, 0);
×
1032
                        break;
×
1033
                    case "diagonal":
×
1034
                        gradient = ctx.createLinearGradient(0, 0, width, height);
×
1035
                        break;
×
1036
                    case "vertical":
×
1037
                    default:
×
1038
                        gradient = ctx.createLinearGradient(0, 0, 0, height);
×
1039
                        break;
×
1040
                }
×
1041
            }
×
1042

1043
            const colors = this.options.backgroundGradientColors;
×
1044
            for (let i = 0; i < colors.length; i++) {
×
1045
                gradient.addColorStop(i / (colors.length - 1), colors[i]);
×
1046
            }
×
1047
            ctx.fillStyle = gradient;
×
1048
        } else {
77✔
1049
            ctx.fillStyle = this.options.backgroundColor;
77✔
1050
        }
77✔
1051

1052
        ctx.fill();
77✔
1053
    }
77✔
1054

1055
    private _createSpeechBubblePath(ctx: CanvasRenderingContext2D, contentX: number, contentY: number,
1✔
1056
        contentWidth: number, contentHeight: number, radius: number,
77✔
1057
        pointerWidth: number, pointerHeight: number, pointerOffset: number,
77✔
1058
        direction: string, curved: boolean): void {
77✔
1059
        switch (direction) {
77✔
1060
            case "bottom": {
77✔
1061
                ctx.moveTo(contentX + radius, contentY);
77✔
1062
                ctx.lineTo(contentX + contentWidth - radius, contentY);
77✔
1063
                ctx.quadraticCurveTo(contentX + contentWidth, contentY, contentX + contentWidth, contentY + radius);
77✔
1064
                ctx.lineTo(contentX + contentWidth, contentY + contentHeight - radius);
77✔
1065
                ctx.quadraticCurveTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth - radius, contentY + contentHeight);
77✔
1066

1067
                const centerXBottom = contentX + (contentWidth / 2) + pointerOffset;
77✔
1068
                ctx.lineTo(Math.min(centerXBottom + (pointerWidth / 2), contentX + contentWidth - radius), contentY + contentHeight);
77✔
1069
                if (curved) {
77✔
1070
                    ctx.quadraticCurveTo(centerXBottom, contentY + contentHeight + pointerHeight, Math.max(centerXBottom - (pointerWidth / 2), contentX + radius), contentY + contentHeight);
77✔
1071
                } else {
77!
1072
                    ctx.lineTo(centerXBottom, contentY + contentHeight + pointerHeight);
×
1073
                    ctx.lineTo(Math.max(centerXBottom - (pointerWidth / 2), contentX + radius), contentY + contentHeight);
×
1074
                }
×
1075

1076
                ctx.lineTo(contentX + radius, contentY + contentHeight);
77✔
1077
                ctx.quadraticCurveTo(contentX, contentY + contentHeight, contentX, contentY + contentHeight - radius);
77✔
1078
                ctx.lineTo(contentX, contentY + radius);
77✔
1079
                ctx.quadraticCurveTo(contentX, contentY, contentX + radius, contentY);
77✔
1080
                break;
77✔
1081
            }
77✔
1082
            case "top": {
77!
1083
                const centerXTop = contentX + (contentWidth / 2) + pointerOffset;
×
1084
                ctx.moveTo(Math.max(centerXTop - (pointerWidth / 2), contentX + radius), contentY);
×
1085
                if (curved) {
×
1086
                    ctx.quadraticCurveTo(centerXTop, contentY - pointerHeight, Math.min(centerXTop + (pointerWidth / 2), contentX + contentWidth - radius), contentY);
×
1087
                } else {
×
1088
                    ctx.lineTo(centerXTop, contentY - pointerHeight);
×
1089
                    ctx.lineTo(Math.min(centerXTop + (pointerWidth / 2), contentX + contentWidth - radius), contentY);
×
1090
                }
×
1091

1092
                ctx.lineTo(contentX + contentWidth - radius, contentY);
×
1093
                ctx.quadraticCurveTo(contentX + contentWidth, contentY, contentX + contentWidth, contentY + radius);
×
1094
                ctx.lineTo(contentX + contentWidth, contentY + contentHeight - radius);
×
1095
                ctx.quadraticCurveTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth - radius, contentY + contentHeight);
×
1096
                ctx.lineTo(contentX + radius, contentY + contentHeight);
×
1097
                ctx.quadraticCurveTo(contentX, contentY + contentHeight, contentX, contentY + contentHeight - radius);
×
1098
                ctx.lineTo(contentX, contentY + radius);
×
1099
                ctx.quadraticCurveTo(contentX, contentY, contentX + radius, contentY);
×
1100
                ctx.lineTo(Math.max(centerXTop - (pointerWidth / 2), contentX + radius), contentY);
×
1101
                break;
×
1102
            }
×
1103
            case "left": {
77!
1104
                const centerYLeft = contentY + (contentHeight / 2) + pointerOffset;
×
1105
                ctx.moveTo(contentX, Math.max(centerYLeft - (pointerWidth / 2), contentY + radius));
×
1106
                if (curved) {
×
1107
                    ctx.quadraticCurveTo(contentX - pointerHeight, centerYLeft, contentX, Math.min(centerYLeft + (pointerWidth / 2), contentY + contentHeight - radius));
×
1108
                } else {
×
1109
                    ctx.lineTo(contentX - pointerHeight, centerYLeft);
×
1110
                    ctx.lineTo(contentX, Math.min(centerYLeft + (pointerWidth / 2), contentY + contentHeight - radius));
×
1111
                }
×
1112

1113
                ctx.lineTo(contentX, contentY + contentHeight - radius);
×
1114
                ctx.quadraticCurveTo(contentX, contentY + contentHeight, contentX + radius, contentY + contentHeight);
×
1115
                ctx.lineTo(contentX + contentWidth - radius, contentY + contentHeight);
×
1116
                ctx.quadraticCurveTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth, contentY + contentHeight - radius);
×
1117
                ctx.lineTo(contentX + contentWidth, contentY + radius);
×
1118
                ctx.quadraticCurveTo(contentX + contentWidth, contentY, contentX + contentWidth - radius, contentY);
×
1119
                ctx.lineTo(contentX + radius, contentY);
×
1120
                ctx.quadraticCurveTo(contentX, contentY, contentX, contentY + radius);
×
1121
                ctx.lineTo(contentX, Math.max(centerYLeft - (pointerWidth / 2), contentY + radius));
×
1122
                break;
×
1123
            }
×
1124
            case "right": {
77!
1125
                ctx.moveTo(contentX + radius, contentY);
×
1126
                ctx.lineTo(contentX + contentWidth - radius, contentY);
×
1127
                ctx.quadraticCurveTo(contentX + contentWidth, contentY, contentX + contentWidth, contentY + radius);
×
1128

1129
                const centerYRight = contentY + (contentHeight / 2) + pointerOffset;
×
1130
                ctx.lineTo(contentX + contentWidth, Math.max(centerYRight - (pointerWidth / 2), contentY + radius));
×
1131
                if (curved) {
×
1132
                    ctx.quadraticCurveTo(contentX + contentWidth + pointerHeight, centerYRight, contentX + contentWidth, Math.min(centerYRight + (pointerWidth / 2), contentY + contentHeight - radius));
×
1133
                } else {
×
1134
                    ctx.lineTo(contentX + contentWidth + pointerHeight, centerYRight);
×
1135
                    ctx.lineTo(contentX + contentWidth, Math.min(centerYRight + (pointerWidth / 2), contentY + contentHeight - radius));
×
1136
                }
×
1137

1138
                ctx.lineTo(contentX + contentWidth, contentY + contentHeight - radius);
×
1139
                ctx.quadraticCurveTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth - radius, contentY + contentHeight);
×
1140
                ctx.lineTo(contentX + radius, contentY + contentHeight);
×
1141
                ctx.quadraticCurveTo(contentX, contentY + contentHeight, contentX, contentY + contentHeight - radius);
×
1142
                ctx.lineTo(contentX, contentY + radius);
×
1143
                ctx.quadraticCurveTo(contentX, contentY, contentX + radius, contentY);
×
1144
                break;
×
1145
            }
×
1146
            default:
77!
1147
                // Unknown direction, draw regular rounded rect
1148
                this._createRoundedRectPath(ctx, contentX, contentY, contentWidth, contentHeight, radius);
×
1149
                break;
×
1150
        }
77✔
1151
    }
77✔
1152

1153
    // Helper method to create counter-clockwise speech bubble path (for holes)
1154
    private _createSpeechBubblePathCCW(ctx: CanvasRenderingContext2D, contentX: number, contentY: number,
1✔
1155
        contentWidth: number, contentHeight: number, radius: number,
×
1156
        pointerWidth: number, pointerHeight: number, pointerOffset: number,
×
1157
        direction: string, curved: boolean): void {
×
1158
        switch (direction) {
×
1159
            case "bottom": {
×
1160
                // Start from bottom-left, go counter-clockwise
1161
                ctx.lineTo(contentX + radius, contentY + contentHeight);
×
1162
                ctx.arcTo(contentX, contentY + contentHeight, contentX, contentY + contentHeight - radius, radius);
×
1163
                ctx.lineTo(contentX, contentY + radius);
×
1164
                ctx.arcTo(contentX, contentY, contentX + radius, contentY, radius);
×
1165
                ctx.lineTo(contentX + contentWidth - radius, contentY);
×
1166
                ctx.arcTo(contentX + contentWidth, contentY, contentX + contentWidth, contentY + radius, radius);
×
1167
                ctx.lineTo(contentX + contentWidth, contentY + contentHeight - radius);
×
1168
                ctx.arcTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth - radius, contentY + contentHeight, radius);
×
1169

1170
                // Pointer part (reversed)
1171
                const centerX = contentX + (contentWidth / 2) + pointerOffset;
×
1172
                ctx.lineTo(Math.min(centerX + (pointerWidth / 2), contentX + contentWidth - radius), contentY + contentHeight);
×
1173
                if (curved) {
×
1174
                    ctx.quadraticCurveTo(centerX, contentY + contentHeight + pointerHeight, Math.max(centerX - (pointerWidth / 2), contentX + radius), contentY + contentHeight);
×
1175
                } else {
×
1176
                    ctx.lineTo(centerX, contentY + contentHeight + pointerHeight);
×
1177
                    ctx.lineTo(Math.max(centerX - (pointerWidth / 2), contentX + radius), contentY + contentHeight);
×
1178
                }
×
1179

1180
                ctx.lineTo(contentX + radius, contentY + contentHeight);
×
1181
                break;
×
1182
            }
×
1183
            case "top": {
×
1184
                // Start from top-right, go counter-clockwise
1185
                const centerXTop = contentX + (contentWidth / 2) + pointerOffset;
×
1186
                ctx.lineTo(Math.min(centerXTop + (pointerWidth / 2), contentX + contentWidth - radius), contentY);
×
1187
                ctx.lineTo(contentX + contentWidth - radius, contentY);
×
1188
                ctx.arcTo(contentX + contentWidth, contentY, contentX + contentWidth, contentY + radius, radius);
×
1189
                ctx.lineTo(contentX + contentWidth, contentY + contentHeight - radius);
×
1190
                ctx.arcTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth - radius, contentY + contentHeight, radius);
×
1191
                ctx.lineTo(contentX + radius, contentY + contentHeight);
×
1192
                ctx.arcTo(contentX, contentY + contentHeight, contentX, contentY + contentHeight - radius, radius);
×
1193
                ctx.lineTo(contentX, contentY + radius);
×
1194
                ctx.arcTo(contentX, contentY, contentX + radius, contentY, radius);
×
1195

1196
                // Pointer part
1197
                ctx.lineTo(Math.max(centerXTop - (pointerWidth / 2), contentX + radius), contentY);
×
1198
                if (curved) {
×
1199
                    ctx.quadraticCurveTo(centerXTop, contentY - pointerHeight, Math.min(centerXTop + (pointerWidth / 2), contentX + contentWidth - radius), contentY);
×
1200
                } else {
×
1201
                    ctx.lineTo(centerXTop, contentY - pointerHeight);
×
1202
                    ctx.lineTo(Math.min(centerXTop + (pointerWidth / 2), contentX + contentWidth - radius), contentY);
×
1203
                }
×
1204

1205
                break;
×
1206
            }
×
1207
            case "left": {
×
1208
                // Similar pattern for left
1209
                const centerYLeft = contentY + (contentHeight / 2) + pointerOffset;
×
1210
                ctx.lineTo(contentX, Math.min(centerYLeft + (pointerWidth / 2), contentY + contentHeight - radius));
×
1211
                ctx.lineTo(contentX, contentY + contentHeight - radius);
×
1212
                ctx.arcTo(contentX, contentY + contentHeight, contentX + radius, contentY + contentHeight, radius);
×
1213
                ctx.lineTo(contentX + contentWidth - radius, contentY + contentHeight);
×
1214
                ctx.arcTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth, contentY + contentHeight - radius, radius);
×
1215
                ctx.lineTo(contentX + contentWidth, contentY + radius);
×
1216
                ctx.arcTo(contentX + contentWidth, contentY, contentX + contentWidth - radius, contentY, radius);
×
1217
                ctx.lineTo(contentX + radius, contentY);
×
1218
                ctx.arcTo(contentX, contentY, contentX, contentY + radius, radius);
×
1219
                ctx.lineTo(contentX, Math.max(centerYLeft - (pointerWidth / 2), contentY + radius));
×
1220
                if (curved) {
×
1221
                    ctx.quadraticCurveTo(contentX - pointerHeight, centerYLeft, contentX, Math.min(centerYLeft + (pointerWidth / 2), contentY + contentHeight - radius));
×
1222
                } else {
×
1223
                    ctx.lineTo(contentX - pointerHeight, centerYLeft);
×
1224
                    ctx.lineTo(contentX, Math.min(centerYLeft + (pointerWidth / 2), contentY + contentHeight - radius));
×
1225
                }
×
1226

1227
                break;
×
1228
            }
×
1229
            case "right": {
×
1230
                // Similar pattern for right
1231
                const centerYRight = contentY + (contentHeight / 2) + pointerOffset;
×
1232
                ctx.lineTo(contentX + contentWidth, Math.max(centerYRight - (pointerWidth / 2), contentY + radius));
×
1233
                ctx.lineTo(contentX + contentWidth, contentY + radius);
×
1234
                ctx.arcTo(contentX + contentWidth, contentY, contentX + contentWidth - radius, contentY, radius);
×
1235
                ctx.lineTo(contentX + radius, contentY);
×
1236
                ctx.arcTo(contentX, contentY, contentX, contentY + radius, radius);
×
1237
                ctx.lineTo(contentX, contentY + contentHeight - radius);
×
1238
                ctx.arcTo(contentX, contentY + contentHeight, contentX + radius, contentY + contentHeight, radius);
×
1239
                ctx.lineTo(contentX + contentWidth - radius, contentY + contentHeight);
×
1240
                ctx.arcTo(contentX + contentWidth, contentY + contentHeight, contentX + contentWidth, contentY + contentHeight - radius, radius);
×
1241
                ctx.lineTo(contentX + contentWidth, Math.min(centerYRight + (pointerWidth / 2), contentY + contentHeight - radius));
×
1242
                if (curved) {
×
1243
                    ctx.quadraticCurveTo(contentX + contentWidth + pointerHeight, centerYRight, contentX + contentWidth, Math.max(centerYRight - (pointerWidth / 2), contentY + radius));
×
1244
                } else {
×
1245
                    ctx.lineTo(contentX + contentWidth + pointerHeight, centerYRight);
×
1246
                    ctx.lineTo(contentX + contentWidth, Math.max(centerYRight - (pointerWidth / 2), contentY + radius));
×
1247
                }
×
1248

1249
                break;
×
1250
            }
×
1251
            default:
×
1252
                // Unknown direction, don't draw anything
1253
                break;
×
1254
        }
×
1255
    }
×
1256

1257
    private _drawRichText(ctx: CanvasRenderingContext2D): void {
1✔
1258
        // Calculate text position relative to content area
1259
        const bgPadding = this.options.backgroundPadding;
2,008✔
1260
        let currentY = this.contentArea.y + this.options.marginTop + bgPadding;
2,008✔
1261

1262
        // Add extra offset for text outline
1263
        if (this.options.textOutline) {
2,008✔
1264
            currentY += this.options.textOutlineWidth;
154✔
1265
        }
154✔
1266

1267
        for (const lineSegments of this.parsedContent) {
2,008✔
1268
            let lineHeight = 0;
2,393✔
1269

1270
            // Calculate line height
1271
            for (const segment of lineSegments) {
2,393✔
1272
                lineHeight = Math.max(lineHeight, segment.style.size);
2,393✔
1273
            }
2,393✔
1274

1275
            // Calculate starting X based on alignment
1276
            let totalWidth = 0;
2,393✔
1277
            for (const segment of lineSegments) {
2,393✔
1278
                const {style} = segment;
2,393✔
1279
                ctx.font = `${style.style} ${style.weight} ${style.size}px ${style.font}`;
2,393✔
1280
                totalWidth += ctx.measureText(segment.text).width;
2,393✔
1281
            }
2,393✔
1282

1283
            let startX: number;
2,393✔
1284
            const contentLeft = this.contentArea.x + this.options.marginLeft + bgPadding;
2,393✔
1285
            const contentRight = this.contentArea.x + this.contentArea.width - this.options.marginRight - bgPadding;
2,393✔
1286
            const contentCenter = this.contentArea.x + (this.contentArea.width / 2);
2,393✔
1287

1288
            switch (this.options.textAlign) {
2,393✔
1289
                case "left":
2,393!
1290
                    startX = contentLeft;
×
1291
                    if (this.options.textOutline) {
×
1292
                        startX += this.options.textOutlineWidth;
×
1293
                    }
×
1294

1295
                    break;
×
1296
                case "right":
2,393!
1297
                    startX = contentRight - totalWidth;
×
1298
                    if (this.options.textOutline) {
×
1299
                        startX -= this.options.textOutlineWidth;
×
1300
                    }
×
1301

1302
                    break;
×
1303
                case "center":
2,393✔
1304
                default:
2,393✔
1305
                    startX = contentCenter - (totalWidth / 2);
2,393✔
1306
                    break;
2,393✔
1307
            }
2,393✔
1308

1309
            // Draw each segment with effects
1310
            let currentX = startX;
2,393✔
1311
            for (const segment of lineSegments) {
2,393✔
1312
                const {style} = segment;
2,393✔
1313

1314
                // Set font
1315
                ctx.font = `${style.style} ${style.weight} ${style.size}px ${style.font}`;
2,393✔
1316
                ctx.textBaseline = "top";
2,393✔
1317

1318
                // Draw background if specified
1319
                if (style.background) {
2,393!
1320
                    const metrics = ctx.measureText(segment.text);
×
1321
                    ctx.fillStyle = style.background;
×
1322
                    ctx.fillRect(currentX, currentY, metrics.width, lineHeight);
×
1323
                }
×
1324

1325
                // Draw text shadow if enabled
1326
                if (this.options.textShadow) {
2,393✔
1327
                    ctx.save();
77✔
1328
                    ctx.shadowColor = this.options.textShadowColor;
77✔
1329
                    ctx.shadowBlur = this.options.textShadowBlur;
77✔
1330
                    ctx.shadowOffsetX = this.options.textShadowOffsetX;
77✔
1331
                    ctx.shadowOffsetY = this.options.textShadowOffsetY;
77✔
1332
                    ctx.fillStyle = style.color;
77✔
1333
                    ctx.fillText(segment.text, currentX, currentY);
77✔
1334
                    ctx.restore();
77✔
1335
                }
77✔
1336

1337
                // Draw text outline if enabled
1338
                if (this.options.textOutline) {
2,393✔
1339
                    ctx.save();
154✔
1340
                    ctx.strokeStyle = this.options.textOutlineColor;
154✔
1341
                    ctx.lineWidth = this.options.textOutlineWidth * 2;
154✔
1342
                    ctx.lineJoin = this.options.textOutlineJoin;
154✔
1343
                    ctx.miterLimit = 2;
154✔
1344
                    ctx.strokeText(segment.text, currentX, currentY);
154✔
1345
                    ctx.restore();
154✔
1346
                }
154✔
1347

1348
                // Draw text fill
1349
                ctx.fillStyle = style.color;
2,393✔
1350
                ctx.fillText(segment.text, currentX, currentY);
2,393✔
1351

1352
                currentX += ctx.measureText(segment.text).width;
2,393✔
1353
            }
2,393✔
1354

1355
            currentY += lineHeight * this.options.lineHeight;
2,393✔
1356
        }
2,393✔
1357
    }
2,008✔
1358

1359
    private _createRoundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number,
1✔
1360
        width: number, height: number, radius: number): void {
2,008✔
1361
        ctx.beginPath();
2,008✔
1362
        ctx.moveTo(x + radius, y);
2,008✔
1363
        ctx.lineTo(x + width - radius, y);
2,008✔
1364
        ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
2,008✔
1365
        ctx.lineTo(x + width, y + height - radius);
2,008✔
1366
        ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
2,008✔
1367
        ctx.lineTo(x + radius, y + height);
2,008✔
1368
        ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
2,008✔
1369
        ctx.lineTo(x, y + radius);
2,008✔
1370
        ctx.quadraticCurveTo(x, y, x + radius, y);
2,008✔
1371
        ctx.closePath();
2,008✔
1372
    }
2,008✔
1373

1374
    private _createMaterial(): void {
1✔
1375
        this.material = new StandardMaterial(`richTextMaterial_${this.id}`, this.scene);
2,008✔
1376
        if (this.texture) {
2,008✔
1377
            this.material.diffuseTexture = this.texture;
2,008✔
1378
        }
2,008✔
1379

1380
        this.material.specularColor = new Color3(0, 0, 0);
2,008✔
1381
        this.material.emissiveColor = new Color3(1, 1, 1);
2,008✔
1382
        this.material.backFaceCulling = false;
2,008✔
1383
        this.material.useAlphaFromDiffuseTexture = true;
2,008✔
1384
        this.material.alphaMode = Engine.ALPHA_COMBINE;
2,008✔
1385
    }
2,008✔
1386

1387
    private _createMesh(): void {
1✔
1388
        // Scale plane size based on fontSize to make it proportional
1389
        // Use a base fontSize of 48 (the default) as reference
1390
        const sizeScale = this.options.fontSize / 48;
2,008✔
1391

1392
        const aspectRatio = this.actualDimensions.width / this.actualDimensions.height;
2,008✔
1393
        const planeHeight = sizeScale;
2,008✔
1394
        const planeWidth = aspectRatio * sizeScale;
2,008✔
1395

1396
        this.mesh = MeshBuilder.CreatePlane(`richTextPlane_${this.id}`, {
2,008✔
1397
            width: planeWidth,
2,008✔
1398
            height: planeHeight,
2,008✔
1399
            sideOrientation: Mesh.DOUBLESIDE,
2,008✔
1400
        }, this.scene);
2,008✔
1401

1402
        this.mesh.material = this.material;
2,008✔
1403
        this.mesh.billboardMode = this.options.billboardMode;
2,008✔
1404
    }
2,008✔
1405

1406
    private _attachToTarget(): void {
1✔
1407
        const target = this.options.attachTo;
2,008✔
1408
        const position = this.options.attachPosition;
2,008✔
1409
        const offset = this.options.attachOffset;
2,008✔
1410

1411
        if (!this.mesh) {
2,008!
1412
            return;
×
1413
        }
×
1414

1415
        let targetPos: Vector3;
2,008✔
1416
        let bounds: {min: Vector3, max: Vector3};
2,008✔
1417

1418
        if (target instanceof Vector3) {
2,008!
1419
            targetPos = target.clone();
×
1420
            bounds = {
×
1421
                min: targetPos.clone(),
×
1422
                max: targetPos.clone(),
×
1423
            };
×
1424
        } else if (target && "getBoundingInfo" in target) {
2,008✔
1425
            this.mesh.parent = target;
2,008✔
1426
            targetPos = Vector3.Zero();
2,008✔
1427

1428
            const boundingInfo = target.getBoundingInfo();
2,008✔
1429
            bounds = {
2,008✔
1430
                min: boundingInfo.boundingBox.minimum,
2,008✔
1431
                max: boundingInfo.boundingBox.maximum,
2,008✔
1432
            };
2,008✔
1433
        } else {
2,008!
1434
            this.mesh.position = new Vector3(
×
1435
                this.options.position.x,
×
1436
                this.options.position.y,
×
1437
                this.options.position.z,
×
1438
            );
×
1439
            return;
×
1440
        }
×
1441

1442
        // Get the actual dimensions of the label mesh
1443
        // The mesh is created with scaled dimensions based on fontSize
1444
        const sizeScale = this.options.fontSize / 48;
2,008✔
1445
        const labelWidth = (this.actualDimensions.width / this.actualDimensions.height) * sizeScale;
2,008✔
1446
        const labelHeight = sizeScale;
2,008✔
1447

1448
        const newPos = targetPos.clone();
2,008✔
1449

1450
        // If pointer is auto, calculate direction
1451
        if (this.options.pointer && this.options.pointerDirection === "auto" && this.pointerInfo) {
2,008!
1452
            // Determine best pointer direction based on attachment position
1453
            switch (position) {
×
1454
                case "top":
×
1455
                case "top-left":
×
1456
                case "top-right":
×
1457
                    this.pointerInfo.direction = "bottom";
×
1458
                    break;
×
1459
                case "bottom":
×
1460
                case "bottom-left":
×
1461
                case "bottom-right":
×
1462
                    this.pointerInfo.direction = "top";
×
1463
                    break;
×
1464
                case "left":
×
1465
                    this.pointerInfo.direction = "right";
×
1466
                    break;
×
1467
                case "right":
×
1468
                    this.pointerInfo.direction = "left";
×
1469
                    break;
×
1470
                default:
×
1471
                    this.pointerInfo.direction = "bottom";
×
1472
            }
×
1473

1474
            // Recalculate dimensions with correct pointer direction
1475
            this._calculateDimensions();
×
1476
            this._drawContent();
×
1477
        }
×
1478

1479
        switch (position) {
2,008✔
1480
            case "top-left":
2,008!
1481
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
1482
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
1483
                break;
×
1484
            case "top":
2,008✔
1485
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
2,008✔
1486
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
2,008✔
1487
                break;
2,008✔
1488
            case "top-right":
2,008!
1489
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
1490
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
1491
                break;
×
1492
            case "left":
2,008!
1493
                // Position the label so its right edge is offset distance from the node's left edge
1494
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
1495
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
1496
                break;
×
1497
            case "center":
2,008!
1498
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
×
1499
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
1500
                break;
×
1501
            case "right":
2,008!
1502
                // Position the label so its left edge is offset distance from the node's right edge
1503
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
1504
                newPos.y = (bounds.min.y + bounds.max.y) / 2;
×
1505
                break;
×
1506
            case "bottom-left":
2,008!
1507
                newPos.x = bounds.min.x - (labelWidth / 2) - offset;
×
1508
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
1509
                break;
×
1510
            case "bottom":
2,008!
1511
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
×
1512
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
1513
                break;
×
1514
            case "bottom-right":
2,008!
1515
                newPos.x = bounds.max.x + (labelWidth / 2) + offset;
×
1516
                newPos.y = bounds.min.y - (labelHeight / 2) - offset;
×
1517
                break;
×
1518
            default:
2,008!
1519
                newPos.x = (bounds.min.x + bounds.max.x) / 2;
×
1520
                newPos.y = bounds.max.y + (labelHeight / 2) + offset;
×
1521
        }
2,008✔
1522

1523
        this.mesh.position = newPos;
2,008✔
1524

1525
        // Store original position for animations
1526
        this.originalPosition ??= newPos.clone();
2,008✔
1527
    }
2,008✔
1528

1529
    private _setupDepthFading(): void {
1✔
1530
        const camera = this.scene.activeCamera;
77✔
1531

1532
        this.scene.registerBeforeRender(() => {
77✔
1533
            if (!camera || !this.mesh || !this.material) {
1,617!
1534
                return;
×
1535
            }
×
1536

1537
            const distance = Vector3.Distance(camera.position, this.mesh.position);
1,617✔
1538

1539
            let fadeFactor = 1.0;
1,617✔
1540
            if (distance < this.options.depthFadeNear) {
1,617✔
1541
                fadeFactor = 1.0;
1,617✔
1542
            } else if (distance > this.options.depthFadeFar) {
1,617!
1543
                fadeFactor = 0.0;
×
1544
            } else {
×
1545
                const fadeRange = this.options.depthFadeFar - this.options.depthFadeNear;
×
1546
                fadeFactor = 1.0 - ((distance - this.options.depthFadeNear) / fadeRange);
×
1547
            }
×
1548

1549
            this.material.alpha = fadeFactor;
1,617✔
1550
        });
77✔
1551
    }
77✔
1552

1553
    private _setupAnimation(): void {
1✔
1554
        this.scene.registerBeforeRender(() => {
154✔
1555
            if (!this.mesh) {
539!
1556
                return;
×
1557
            }
×
1558

1559
            this.animationTime += 0.016 * this.options.animationSpeed;
539✔
1560

1561
            switch (this.options.animation) {
539✔
1562
                case "pulse": {
539✔
1563
                    const scale = 1 + (Math.sin(this.animationTime * 3) * 0.1);
539✔
1564
                    this.mesh.scaling.x = scale;
539✔
1565
                    this.mesh.scaling.y = scale;
539✔
1566
                    break;
539✔
1567
                }
539✔
1568
                case "bounce": {
539!
1569
                    if (this.originalPosition) {
×
1570
                        const bounce = Math.abs(Math.sin(this.animationTime * 2)) * 0.3;
×
1571
                        this.mesh.position.y = this.originalPosition.y + bounce;
×
1572
                    }
×
1573

1574
                    break;
×
1575
                }
×
1576
                case "shake": {
539!
1577
                    if (this.originalPosition) {
×
1578
                        const shakeX = Math.sin(this.animationTime * 20) * 0.02;
×
1579
                        const shakeY = Math.cos(this.animationTime * 25) * 0.02;
×
1580
                        this.mesh.position.x = this.originalPosition.x + shakeX;
×
1581
                        this.mesh.position.y = this.originalPosition.y + shakeY;
×
1582
                    }
×
1583

1584
                    break;
×
1585
                }
×
1586
                case "glow": {
539!
1587
                    const glow = 0.8 + (Math.sin(this.animationTime * 2) * 0.2);
×
1588
                    if (this.material) {
×
1589
                        this.material.emissiveColor = new Color3(glow, glow, glow);
×
1590
                    }
×
1591

1592
                    break;
×
1593
                }
×
1594
                case "fill": {
539!
1595
                    if (this.options._progressBar) {
×
1596
                        this._progressValue = (Math.sin(this.animationTime) + 1) / 2;
×
1597
                        this._drawContent();
×
1598
                    }
×
1599

1600
                    break;
×
1601
                }
×
1602
                default:
539!
1603
                    // No animation or unknown animation type
1604
                    break;
×
1605
            }
539✔
1606
        });
154✔
1607
    }
154✔
1608

1609
    // Public methods
1610
    public setText(text: string): void {
1✔
1611
        // Apply smart overflow if enabled
NEW
1612
        if (this.options.smartOverflow && !isNaN(Number(text))) {
×
NEW
1613
            const num = parseInt(text);
×
NEW
1614
            if (num > this.options.maxNumber) {
×
NEW
1615
                if (num >= 1000) {
×
NEW
1616
                    this.options.text = `${Math.floor(num / 1000)}k`;
×
NEW
1617
                } else {
×
NEW
1618
                    this.options.text = `${this.options.maxNumber}${this.options.overflowSuffix}`;
×
NEW
1619
                }
×
NEW
1620
            } else {
×
NEW
1621
                this.options.text = text;
×
NEW
1622
            }
×
NEW
1623
        } else {
×
NEW
1624
            this.options.text = text;
×
NEW
1625
        }
×
1626

1627
        this._parseRichText();
×
1628
        this._calculateDimensions();
×
1629
        this._drawContent();
×
1630
    }
×
1631

1632
    public setProgress(value: number): void {
1✔
1633
        this._progressValue = Math.max(0, Math.min(1, value));
×
1634
        this._drawContent();
×
1635
    }
×
1636

1637
    public attachTo(target: AbstractMesh | Vector3, position: AttachPosition = "top", offset = 0.5): void {
1✔
1638
        this.options.attachTo = target;
×
1639
        this.options.attachPosition = position;
×
1640
        this.options.attachOffset = offset;
×
1641

1642
        if (this.mesh?.parent && this.mesh.parent !== target) {
×
1643
            this.mesh.parent = null;
×
1644
        }
×
1645

1646
        this._attachToTarget();
×
1647
    }
×
1648

1649
    public dispose(): void {
1✔
1650
        if (this.mesh) {
×
1651
            this.mesh.dispose();
×
1652
        }
×
1653

1654
        if (this.material) {
×
1655
            this.material.dispose();
×
1656
        }
×
1657

1658
        if (this.texture) {
×
1659
            this.texture.dispose();
×
1660
        }
×
1661
    }
×
1662

1663
    // Getters
1664
    public get labelMesh(): Mesh | null {
1✔
1665
        return this.mesh;
×
1666
    }
×
1667

1668
    public get labelId(): string {
1✔
1669
        return this.id;
×
1670
    }
×
1671
}
1✔
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