diff --git a/public/fonts/cormorant.json b/public/fonts/cormorant.json index 21f40173..cfa6736f 100644 --- a/public/fonts/cormorant.json +++ b/public/fonts/cormorant.json @@ -7,10 +7,10 @@ "id": 181, "index": 0, "char": "µ", - "width": 17, - "height": 46, - "xoffset": 2, - "yoffset": 7, + "width": 29, + "height": 58, + "xoffset": -4, + "yoffset": 1, "xadvance": 21, "chnl": 15, "x": 0, @@ -21,27 +21,27 @@ "id": 87, "index": 230, "char": "W", - "width": 43, - "height": 30, - "xoffset": -2, - "yoffset": 11, + "width": 55, + "height": 42, + "xoffset": -8, + "yoffset": 5, "xadvance": 39, "chnl": 15, "x": 0, - "y": 48, + "y": 70, "page": 0 }, { "id": 47, "index": 2412, "char": "/", - "width": 16, - "height": 42, - "xoffset": -1, - "yoffset": 6, + "width": 28, + "height": 54, + "xoffset": -7, + "yoffset": 0, "xadvance": 15, "chnl": 15, - "x": 19, + "x": 41, "y": 0, "page": 0 }, @@ -49,195 +49,195 @@ "id": 92, "index": 2413, "char": "\\", - "width": 16, - "height": 42, - "xoffset": -1, - "yoffset": 6, + "width": 28, + "height": 54, + "xoffset": -7, + "yoffset": 0, "xadvance": 15, "chnl": 15, - "x": 37, - "y": 0, + "x": 0, + "y": 124, "page": 0 }, { "id": 124, "index": 2516, "char": "|", - "width": 6, - "height": 42, - "xoffset": 1, - "yoffset": 6, + "width": 18, + "height": 54, + "xoffset": -5, + "yoffset": 0, "xadvance": 7, "chnl": 15, "x": 0, - "y": 80, + "y": 190, "page": 0 }, { "id": 106, "index": 574, "char": "j", - "width": 11, - "height": 41, - "xoffset": -2, - "yoffset": 12, + "width": 23, + "height": 53, + "xoffset": -8, + "yoffset": 6, "xadvance": 11, "chnl": 15, "x": 0, - "y": 124, + "y": 256, "page": 0 }, { "id": 74, "index": 70, "char": "J", - "width": 19, - "height": 40, - "xoffset": -4, - "yoffset": 11, + "width": 31, + "height": 52, + "xoffset": -10, + "yoffset": 5, "xadvance": 14, "chnl": 15, - "x": 8, - "y": 80, + "x": 30, + "y": 190, "page": 0 }, { "id": 40, "index": 2452, "char": "(", - "width": 14, - "height": 39, - "xoffset": 0, - "yoffset": 7, + "width": 26, + "height": 51, + "xoffset": -6, + "yoffset": 1, "xadvance": 13, "chnl": 15, - "x": 0, - "y": 167, + "x": 40, + "y": 124, "page": 0 }, { "id": 41, "index": 2453, "char": ")", - "width": 14, - "height": 39, - "xoffset": -1, - "yoffset": 7, + "width": 26, + "height": 51, + "xoffset": -7, + "yoffset": 1, "xadvance": 13, "chnl": 15, - "x": 13, - "y": 122, + "x": 0, + "y": 321, "page": 0 }, { "id": 81, "index": 181, "char": "Q", - "width": 33, - "height": 39, - "xoffset": 0, - "yoffset": 10, + "width": 45, + "height": 51, + "xoffset": -6, + "yoffset": 4, "xadvance": 32, "chnl": 15, - "x": 0, - "y": 208, + "x": 35, + "y": 254, "page": 0 }, { "id": 91, "index": 2456, "char": "[", - "width": 11, - "height": 39, - "xoffset": 1, - "yoffset": 7, + "width": 23, + "height": 51, + "xoffset": -5, + "yoffset": 1, "xadvance": 12, "chnl": 15, - "x": 16, - "y": 163, + "x": 73, + "y": 187, "page": 0 }, { "id": 93, "index": 2457, "char": "]", - "width": 11, - "height": 39, - "xoffset": -1, - "yoffset": 7, + "width": 23, + "height": 51, + "xoffset": -7, + "yoffset": 1, "xadvance": 12, "chnl": 15, - "x": 29, - "y": 80, + "x": 78, + "y": 66, "page": 0 }, { "id": 123, "index": 2454, "char": "{", - "width": 13, - "height": 39, - "xoffset": -1, - "yoffset": 7, + "width": 25, + "height": 51, + "xoffset": -7, + "yoffset": 1, "xadvance": 12, "chnl": 15, - "x": 29, - "y": 121, + "x": 81, + "y": 0, "page": 0 }, { "id": 125, "index": 2455, "char": "}", - "width": 13, - "height": 39, - "xoffset": -1, - "yoffset": 7, + "width": 25, + "height": 51, + "xoffset": -7, + "yoffset": 1, "xadvance": 12, "chnl": 15, - "x": 42, - "y": 80, + "x": 0, + "y": 384, "page": 0 }, { "id": 77, "index": 86, "char": "M", - "width": 38, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 50, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 36, "chnl": 15, - "x": 45, - "y": 44, + "x": 78, + "y": 129, "page": 0 }, { "id": 98, "index": 526, "char": "b", - "width": 24, - "height": 35, - "xoffset": -2, - "yoffset": 7, + "width": 36, + "height": 47, + "xoffset": -8, + "yoffset": 1, "xadvance": 22, "chnl": 15, - "x": 55, - "y": 0, + "x": 113, + "y": 63, "page": 0 }, { "id": 100, "index": 533, "char": "d", - "width": 24, - "height": 35, - "xoffset": -1, - "yoffset": 7, + "width": 36, + "height": 47, + "xoffset": -7, + "yoffset": 1, "xadvance": 21, "chnl": 15, - "x": 81, + "x": 118, "y": 0, "page": 0 }, @@ -245,1120 +245,1120 @@ "id": 109, "index": 592, "char": "m", - "width": 35, - "height": 21, - "xoffset": -2, - "yoffset": 20, + "width": 47, + "height": 33, + "xoffset": -8, + "yoffset": 14, "xadvance": 32, "chnl": 15, - "x": 29, - "y": 162, + "x": 0, + "y": 447, "page": 0 }, { "id": 65, "index": 311, "char": "A", - "width": 34, - "height": 31, - "xoffset": -2, - "yoffset": 10, + "width": 46, + "height": 43, + "xoffset": -8, + "yoffset": 4, "xadvance": 30, "chnl": 15, - "x": 44, - "y": 121, + "x": 37, + "y": 384, "page": 0 }, { "id": 102, "index": 541, "char": "f", - "width": 21, - "height": 34, - "xoffset": -1, - "yoffset": 7, + "width": 33, + "height": 46, + "xoffset": -7, + "yoffset": 1, "xadvance": 13, "chnl": 15, - "x": 57, - "y": 76, + "x": 38, + "y": 317, "page": 0 }, { "id": 104, "index": 543, "char": "h", - "width": 25, - "height": 34, - "xoffset": -2, - "yoffset": 7, + "width": 37, + "height": 46, + "xoffset": -8, + "yoffset": 1, "xadvance": 21, "chnl": 15, - "x": 0, - "y": 249, + "x": 83, + "y": 317, "page": 0 }, { "id": 107, "index": 578, "char": "k", - "width": 25, - "height": 34, - "xoffset": -2, - "yoffset": 7, + "width": 37, + "height": 46, + "xoffset": -8, + "yoffset": 1, "xadvance": 21, "chnl": 15, - "x": 0, - "y": 285, + "x": 92, + "y": 250, "page": 0 }, { "id": 108, "index": 583, "char": "l", - "width": 13, - "height": 34, - "xoffset": -1, - "yoffset": 7, + "width": 25, + "height": 46, + "xoffset": -7, + "yoffset": 1, "xadvance": 11, "chnl": 15, - "x": 0, - "y": 321, + "x": 108, + "y": 183, "page": 0 }, { "id": 119, "index": 747, "char": "w", - "width": 34, - "height": 20, - "xoffset": -3, - "yoffset": 21, + "width": 46, + "height": 32, + "xoffset": -9, + "yoffset": 15, "xadvance": 29, "chnl": 15, - "x": 29, - "y": 185, + "x": 140, + "y": 122, "page": 0 }, { "id": 51, "index": 2269, "char": "3", - "width": 18, - "height": 33, - "xoffset": -1, - "yoffset": 20, + "width": 30, + "height": 45, + "xoffset": -7, + "yoffset": 14, "xadvance": 16, "chnl": 15, - "x": 0, - "y": 357, + "x": 161, + "y": 59, "page": 0 }, { "id": 53, "index": 2271, "char": "5", - "width": 17, - "height": 33, - "xoffset": 0, - "yoffset": 19, + "width": 29, + "height": 45, + "xoffset": -6, + "yoffset": 13, "xadvance": 17, "chnl": 15, - "x": 15, - "y": 321, + "x": 166, + "y": 0, "page": 0 }, { "id": 55, "index": 2273, "char": "7", - "width": 20, - "height": 33, - "xoffset": -1, - "yoffset": 20, + "width": 32, + "height": 45, + "xoffset": -7, + "yoffset": 14, "xadvance": 18, "chnl": 15, - "x": 27, - "y": 249, + "x": 59, + "y": 439, "page": 0 }, { "id": 72, "index": 43, "char": "H", - "width": 33, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 45, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 32, "chnl": 15, - "x": 27, - "y": 284, + "x": 95, + "y": 375, "page": 0 }, { "id": 78, "index": 98, "char": "N", - "width": 33, - "height": 31, - "xoffset": -1, - "yoffset": 11, + "width": 45, + "height": 43, + "xoffset": -7, + "yoffset": 5, "xadvance": 31, "chnl": 15, - "x": 35, - "y": 207, + "x": 132, + "y": 308, "page": 0 }, { "id": 36, "index": 2554, "char": "$", - "width": 18, - "height": 32, - "xoffset": 0, - "yoffset": 13, + "width": 30, + "height": 44, + "xoffset": -6, + "yoffset": 7, "xadvance": 18, "chnl": 15, - "x": 49, - "y": 240, + "x": 141, + "y": 241, "page": 0 }, { "id": 38, "index": 2506, "char": "&", - "width": 31, - "height": 32, - "xoffset": 1, - "yoffset": 10, + "width": 43, + "height": 44, + "xoffset": -5, + "yoffset": 4, "xadvance": 30, "chnl": 15, - "x": 66, - "y": 154, + "x": 145, + "y": 166, "page": 0 }, { "id": 54, "index": 2272, "char": "6", - "width": 21, - "height": 32, - "xoffset": 0, - "yoffset": 9, + "width": 33, + "height": 44, + "xoffset": -6, + "yoffset": 3, "xadvance": 20, "chnl": 15, - "x": 80, - "y": 76, + "x": 183, + "y": 222, "page": 0 }, { "id": 57, "index": 2275, "char": "9", - "width": 21, - "height": 32, - "xoffset": -1, - "yoffset": 20, + "width": 33, + "height": 44, + "xoffset": -7, + "yoffset": 14, "xadvance": 20, "chnl": 15, - "x": 85, - "y": 37, + "x": 103, + "y": 429, "page": 0 }, { "id": 79, "index": 117, "char": "O", - "width": 32, - "height": 31, - "xoffset": 0, - "yoffset": 10, + "width": 44, + "height": 43, + "xoffset": -6, + "yoffset": 4, "xadvance": 32, "chnl": 15, - "x": 107, - "y": 0, + "x": 148, + "y": 429, "page": 0 }, { "id": 82, "index": 350, "char": "R", - "width": 32, - "height": 30, - "xoffset": 0, - "yoffset": 11, + "width": 44, + "height": 42, + "xoffset": -6, + "yoffset": 5, "xadvance": 29, "chnl": 15, - "x": 80, - "y": 110, + "x": 152, + "y": 363, "page": 0 }, { "id": 85, "index": 199, "char": "U", - "width": 32, - "height": 31, - "xoffset": -1, - "yoffset": 11, + "width": 44, + "height": 43, + "xoffset": -7, + "yoffset": 5, "xadvance": 29, "chnl": 15, - "x": 103, - "y": 71, + "x": 189, + "y": 278, "page": 0 }, { "id": 86, "index": 229, "char": "V", - "width": 32, - "height": 30, - "xoffset": -2, - "yoffset": 11, + "width": 44, + "height": 42, + "xoffset": -8, + "yoffset": 5, "xadvance": 28, "chnl": 15, - "x": 108, - "y": 33, + "x": 200, + "y": 116, "page": 0 }, { "id": 90, "index": 246, "char": "Z", - "width": 25, - "height": 32, - "xoffset": 0, - "yoffset": 9, + "width": 37, + "height": 44, + "xoffset": -6, + "yoffset": 3, "xadvance": 25, "chnl": 15, - "x": 0, - "y": 392, + "x": 203, + "y": 57, "page": 0 }, { "id": 103, "index": 908, "char": "g", - "width": 21, - "height": 32, - "xoffset": -1, - "yoffset": 20, + "width": 33, + "height": 44, + "xoffset": -7, + "yoffset": 14, "xadvance": 19, "chnl": 15, - "x": 20, - "y": 356, + "x": 207, + "y": 0, "page": 0 }, { "id": 112, "index": 686, "char": "p", - "width": 24, - "height": 32, - "xoffset": -2, - "yoffset": 20, + "width": 36, + "height": 44, + "xoffset": -8, + "yoffset": 14, "xadvance": 22, "chnl": 15, - "x": 34, - "y": 316, + "x": 204, + "y": 417, "page": 0 }, { "id": 113, "index": 688, "char": "q", - "width": 23, - "height": 32, - "xoffset": -1, - "yoffset": 20, + "width": 35, + "height": 44, + "xoffset": -7, + "yoffset": 14, "xadvance": 21, "chnl": 15, - "x": 0, - "y": 426, + "x": 208, + "y": 333, "page": 0 }, { "id": 121, "index": 753, "char": "y", - "width": 24, - "height": 32, - "xoffset": -4, - "yoffset": 21, + "width": 36, + "height": 44, + "xoffset": -10, + "yoffset": 15, "xadvance": 18, "chnl": 15, - "x": 0, - "y": 460, + "x": 228, + "y": 170, "page": 0 }, { "id": 33, "index": 2401, "char": "!", - "width": 9, - "height": 31, - "xoffset": 1, - "yoffset": 11, + "width": 21, + "height": 43, + "xoffset": -5, + "yoffset": 5, "xadvance": 11, "chnl": 15, - "x": 141, - "y": 0, + "x": 245, + "y": 226, "page": 0 }, { "id": 63, "index": 2403, "char": "?", - "width": 16, - "height": 31, - "xoffset": -1, - "yoffset": 11, + "width": 28, + "height": 43, + "xoffset": -7, + "yoffset": 5, "xadvance": 14, "chnl": 15, - "x": 25, - "y": 426, + "x": 252, + "y": 0, "page": 0 }, { "id": 66, "index": 9, "char": "B", - "width": 25, - "height": 31, - "xoffset": 0, - "yoffset": 11, + "width": 37, + "height": 43, + "xoffset": -6, + "yoffset": 5, "xadvance": 24, "chnl": 15, - "x": 27, - "y": 390, + "x": 252, + "y": 55, "page": 0 }, { "id": 67, "index": 338, "char": "C", - "width": 29, - "height": 31, - "xoffset": 0, - "yoffset": 10, + "width": 41, + "height": 43, + "xoffset": -6, + "yoffset": 4, "xadvance": 29, "chnl": 15, - "x": 43, - "y": 350, + "x": 292, + "y": 0, "page": 0 }, { "id": 68, "index": 10, "char": "D", - "width": 30, - "height": 31, - "xoffset": -1, - "yoffset": 11, + "width": 42, + "height": 43, + "xoffset": -7, + "yoffset": 5, "xadvance": 29, "chnl": 15, - "x": 60, - "y": 316, + "x": 256, + "y": 110, "page": 0 }, { "id": 71, "index": 345, "char": "G", - "width": 31, - "height": 31, - "xoffset": 0, - "yoffset": 10, + "width": 43, + "height": 43, + "xoffset": -6, + "yoffset": 4, "xadvance": 30, "chnl": 15, - "x": 62, - "y": 274, + "x": 301, + "y": 55, "page": 0 }, { "id": 83, "index": 182, "char": "S", - "width": 20, - "height": 31, - "xoffset": 1, - "yoffset": 10, + "width": 32, + "height": 43, + "xoffset": -5, + "yoffset": 4, "xadvance": 21, "chnl": 15, - "x": 69, - "y": 240, + "x": 345, + "y": 0, "page": 0 }, { "id": 84, "index": 192, "char": "T", - "width": 28, - "height": 31, - "xoffset": 0, - "yoffset": 10, + "width": 40, + "height": 43, + "xoffset": -6, + "yoffset": 4, "xadvance": 27, "chnl": 15, - "x": 70, - "y": 188, + "x": 276, + "y": 165, "page": 0 }, { "id": 88, "index": 235, "char": "X", - "width": 31, - "height": 30, - "xoffset": -2, - "yoffset": 11, + "width": 43, + "height": 42, + "xoffset": -8, + "yoffset": 5, "xadvance": 27, "chnl": 15, - "x": 99, - "y": 142, + "x": 310, + "y": 110, "page": 0 }, { "id": 69, "index": 16, "char": "E", - "width": 23, - "height": 30, - "xoffset": 0, - "yoffset": 11, + "width": 35, + "height": 42, + "xoffset": -6, + "yoffset": 5, "xadvance": 23, "chnl": 15, - "x": 114, - "y": 104, + "x": 356, + "y": 55, "page": 0 }, { "id": 70, "index": 40, "char": "F", - "width": 22, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 34, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 22, "chnl": 15, - "x": 137, - "y": 65, + "x": 389, + "y": 0, "page": 0 }, { "id": 73, "index": 48, "char": "I", - "width": 15, - "height": 30, - "xoffset": 0, - "yoffset": 11, + "width": 27, + "height": 42, + "xoffset": -6, + "yoffset": 5, "xadvance": 14, "chnl": 15, - "x": 142, - "y": 33, + "x": 435, + "y": 0, "page": 0 }, { "id": 75, "index": 73, "char": "K", - "width": 30, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 42, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 27, "chnl": 15, - "x": 152, - "y": 0, + "x": 403, + "y": 54, "page": 0 }, { "id": 76, "index": 77, "char": "L", - "width": 25, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 37, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 23, "chnl": 15, - "x": 159, - "y": 32, + "x": 474, + "y": 0, "page": 0 }, { "id": 80, "index": 179, "char": "P", - "width": 24, - "height": 30, - "xoffset": -1, - "yoffset": 11, + "width": 36, + "height": 42, + "xoffset": -7, + "yoffset": 5, "xadvance": 23, "chnl": 15, - "x": 184, - "y": 0, + "x": 457, + "y": 54, "page": 0 }, { "id": 89, "index": 236, "char": "Y", - "width": 30, - "height": 30, - "xoffset": -2, - "yoffset": 11, + "width": 42, + "height": 42, + "xoffset": -8, + "yoffset": 5, "xadvance": 26, "chnl": 15, - "x": 91, - "y": 221, + "x": 403, + "y": 108, "page": 0 }, { "id": 52, "index": 2270, "char": "4", - "width": 21, - "height": 29, - "xoffset": -1, - "yoffset": 20, + "width": 33, + "height": 41, + "xoffset": -7, + "yoffset": 14, "xadvance": 19, "chnl": 15, - "x": 100, - "y": 174, + "x": 457, + "y": 108, "page": 0 }, { "id": 56, "index": 2274, "char": "8", - "width": 22, - "height": 29, - "xoffset": 0, - "yoffset": 13, + "width": 34, + "height": 41, + "xoffset": -6, + "yoffset": 7, "xadvance": 21, "chnl": 15, - "x": 26, - "y": 459, + "x": 457, + "y": 161, "page": 0 }, { "id": 64, "index": 2505, "char": "@", - "width": 29, - "height": 29, - "xoffset": 1, - "yoffset": 16, + "width": 41, + "height": 41, + "xoffset": -5, + "yoffset": 10, "xadvance": 30, "chnl": 15, - "x": 43, - "y": 423, + "x": 365, + "y": 162, "page": 0 }, { "id": 105, "index": 548, "char": "i", - "width": 13, - "height": 29, - "xoffset": -1, - "yoffset": 12, + "width": 25, + "height": 41, + "xoffset": -7, + "yoffset": 6, "xadvance": 11, "chnl": 15, - "x": 54, - "y": 383, + "x": 365, + "y": 109, "page": 0 }, { "id": 35, "index": 2411, "char": "#", - "width": 21, - "height": 28, - "xoffset": 0, - "yoffset": 17, + "width": 33, + "height": 40, + "xoffset": -6, + "yoffset": 11, "xadvance": 22, "chnl": 15, - "x": 69, - "y": 383, + "x": 245, + "y": 281, "page": 0 }, { "id": 37, "index": 2612, "char": "%", - "width": 26, - "height": 28, - "xoffset": -1, - "yoffset": 13, + "width": 38, + "height": 40, + "xoffset": -7, + "yoffset": 7, "xadvance": 24, "chnl": 15, - "x": 74, - "y": 349, + "x": 278, + "y": 220, "page": 0 }, { "id": 59, "index": 2399, "char": ";", - "width": 9, - "height": 28, - "xoffset": 0, - "yoffset": 21, + "width": 21, + "height": 40, + "xoffset": -6, + "yoffset": 15, "xadvance": 10, "chnl": 15, - "x": 92, - "y": 307, + "x": 328, + "y": 164, "page": 0 }, { "id": 110, "index": 604, "char": "n", - "width": 25, - "height": 21, - "xoffset": -2, - "yoffset": 20, + "width": 37, + "height": 33, + "xoffset": -8, + "yoffset": 14, "xadvance": 22, "chnl": 15, - "x": 26, - "y": 490, + "x": 204, + "y": 473, "page": 0 }, { "id": 117, "index": 716, "char": "u", - "width": 25, - "height": 21, - "xoffset": -2, - "yoffset": 20, + "width": 37, + "height": 33, + "xoffset": -8, + "yoffset": 14, "xadvance": 21, "chnl": 15, - "x": 50, - "y": 454, + "x": 252, + "y": 389, "page": 0 }, { "id": 116, "index": 707, "char": "t", - "width": 17, - "height": 24, - "xoffset": -1, - "yoffset": 18, + "width": 29, + "height": 36, + "xoffset": -7, + "yoffset": 12, "xadvance": 14, "chnl": 15, - "x": 53, - "y": 477, + "x": 255, + "y": 333, "page": 0 }, { "id": 118, "index": 746, "char": "v", - "width": 23, - "height": 20, - "xoffset": -3, - "yoffset": 21, + "width": 35, + "height": 32, + "xoffset": -9, + "yoffset": 15, "xadvance": 18, "chnl": 15, - "x": 72, - "y": 477, + "x": 290, + "y": 272, "page": 0 }, { "id": 48, "index": 2267, "char": "0", - "width": 22, - "height": 21, - "xoffset": -1, - "yoffset": 20, + "width": 34, + "height": 33, + "xoffset": -7, + "yoffset": 14, "xadvance": 20, "chnl": 15, - "x": 74, - "y": 413, + "x": 328, + "y": 216, "page": 0 }, { "id": 111, "index": 624, "char": "o", - "width": 22, - "height": 21, - "xoffset": -1, - "yoffset": 20, + "width": 34, + "height": 33, + "xoffset": -7, + "yoffset": 14, "xadvance": 20, "chnl": 15, - "x": 92, - "y": 379, + "x": 296, + "y": 316, "page": 0 }, { "id": 120, "index": 752, "char": "x", - "width": 22, - "height": 20, - "xoffset": -2, - "yoffset": 21, + "width": 34, + "height": 32, + "xoffset": -8, + "yoffset": 15, "xadvance": 18, "chnl": 15, - "x": 77, - "y": 436, + "x": 337, + "y": 261, "page": 0 }, { "id": 122, "index": 763, "char": "z", - "width": 18, - "height": 22, - "xoffset": -1, - "yoffset": 19, + "width": 30, + "height": 34, + "xoffset": -7, + "yoffset": 13, "xadvance": 17, "chnl": 15, - "x": 98, - "y": 402, + "x": 374, + "y": 215, "page": 0 }, { "id": 177, "index": 2600, "char": "±", - "width": 18, - "height": 22, - "xoffset": -1, - "yoffset": 19, + "width": 30, + "height": 34, + "xoffset": -7, + "yoffset": 13, "xadvance": 17, "chnl": 15, - "x": 102, - "y": 337, + "x": 253, + "y": 434, "page": 0 }, { "id": 50, "index": 2268, "char": "2", - "width": 18, - "height": 21, - "xoffset": -1, - "yoffset": 20, + "width": 30, + "height": 33, + "xoffset": -7, + "yoffset": 14, "xadvance": 17, "chnl": 15, - "x": 116, - "y": 361, + "x": 295, + "y": 434, "page": 0 }, { "id": 61, "index": 2594, "char": "=", - "width": 21, - "height": 12, - "xoffset": -1, - "yoffset": 25, + "width": 33, + "height": 24, + "xoffset": -7, + "yoffset": 19, "xadvance": 20, "chnl": 15, - "x": 72, - "y": 499, + "x": 103, + "y": 485, "page": 0 }, { "id": 97, "index": 855, "char": "a", - "width": 20, - "height": 21, - "xoffset": 0, - "yoffset": 20, + "width": 32, + "height": 33, + "xoffset": -6, + "yoffset": 14, "xadvance": 18, "chnl": 15, - "x": 95, - "y": 253, + "x": 295, + "y": 479, "page": 0 }, { "id": 99, "index": 527, "char": "c", - "width": 19, - "height": 21, - "xoffset": -1, - "yoffset": 20, + "width": 31, + "height": 33, + "xoffset": -7, + "yoffset": 14, "xadvance": 18, "chnl": 15, - "x": 95, - "y": 276, + "x": 301, + "y": 361, "page": 0 }, { "id": 101, "index": 885, "char": "e", - "width": 19, - "height": 21, - "xoffset": -1, - "yoffset": 20, + "width": 31, + "height": 33, + "xoffset": -7, + "yoffset": 14, "xadvance": 17, "chnl": 15, - "x": 103, - "y": 299, + "x": 342, + "y": 305, "page": 0 }, { "id": 114, "index": 689, "char": "r", - "width": 19, - "height": 21, - "xoffset": -2, - "yoffset": 20, + "width": 31, + "height": 33, + "xoffset": -8, + "yoffset": 14, "xadvance": 16, "chnl": 15, - "x": 116, - "y": 276, + "x": 337, + "y": 406, "page": 0 }, { "id": 115, "index": 698, "char": "s", - "width": 15, - "height": 21, - "xoffset": 0, - "yoffset": 20, + "width": 27, + "height": 33, + "xoffset": -6, + "yoffset": 14, "xadvance": 14, "chnl": 15, - "x": 117, - "y": 253, + "x": 418, + "y": 162, "page": 0 }, { "id": 42, "index": 2407, "char": "*", - "width": 20, - "height": 20, - "xoffset": 0, - "yoffset": 8, + "width": 32, + "height": 32, + "xoffset": -6, + "yoffset": 2, "xadvance": 19, "chnl": 15, - "x": 122, - "y": 322, + "x": 383, + "y": 261, "page": 0 }, { "id": 49, "index": 2297, "char": "1", - "width": 14, - "height": 20, - "xoffset": 0, - "yoffset": 21, + "width": 26, + "height": 32, + "xoffset": -6, + "yoffset": 15, "xadvance": 14, "chnl": 15, - "x": 124, - "y": 299, + "x": 253, + "y": 480, "page": 0 }, { "id": 58, "index": 2398, "char": ":", - "width": 9, - "height": 20, - "xoffset": 0, - "yoffset": 21, + "width": 21, + "height": 32, + "xoffset": -6, + "yoffset": 15, "xadvance": 9, "chnl": 15, - "x": 97, - "y": 458, + "x": 416, + "y": 215, "page": 0 }, { "id": 60, "index": 2597, "char": "<", - "width": 19, - "height": 18, - "xoffset": -1, - "yoffset": 22, + "width": 31, + "height": 30, + "xoffset": -7, + "yoffset": 16, "xadvance": 17, "chnl": 15, - "x": 0, - "y": 494, + "x": 344, + "y": 350, "page": 0 }, { "id": 62, "index": 2596, "char": ">", - "width": 19, - "height": 18, - "xoffset": -1, - "yoffset": 22, + "width": 31, + "height": 30, + "xoffset": -7, + "yoffset": 16, "xadvance": 17, "chnl": 15, - "x": 101, - "y": 426, + "x": 385, + "y": 305, "page": 0 }, { "id": 126, "index": 2602, "char": "~", - "width": 19, - "height": 8, - "xoffset": 0, - "yoffset": 27, + "width": 31, + "height": 20, + "xoffset": -6, + "yoffset": 21, "xadvance": 19, "chnl": 15, - "x": 70, - "y": 221, + "x": 0, + "y": 492, "page": 0 }, { "id": 43, "index": 2590, "char": "+", - "width": 18, - "height": 18, - "xoffset": -1, - "yoffset": 22, + "width": 30, + "height": 30, + "xoffset": -7, + "yoffset": 16, "xadvance": 17, "chnl": 15, - "x": 97, - "y": 480, + "x": 427, + "y": 259, "page": 0 }, { "id": 95, "index": 2443, "char": "_", - "width": 18, - "height": 6, - "xoffset": -1, - "yoffset": 38, + "width": 30, + "height": 18, + "xoffset": -7, + "yoffset": 32, "xadvance": 17, "chnl": 15, - "x": 44, - "y": 154, + "x": 148, + "y": 484, "page": 0 }, { "id": 34, "index": 2482, "char": "\"", - "width": 14, - "height": 16, - "xoffset": -1, - "yoffset": 10, + "width": 26, + "height": 28, + "xoffset": -7, + "yoffset": 4, "xadvance": 12, "chnl": 15, - "x": 116, - "y": 384, + "x": 449, + "y": 214, "page": 0 }, { "id": 39, "index": 2483, "char": "'", - "width": 8, - "height": 16, - "xoffset": -1, - "yoffset": 10, + "width": 20, + "height": 28, + "xoffset": -7, + "yoffset": 4, "xadvance": 6, "chnl": 15, - "x": 102, - "y": 361, + "x": 487, + "y": 214, "page": 0 }, { "id": 44, "index": 2397, "char": ",", - "width": 9, - "height": 16, - "xoffset": 0, - "yoffset": 33, + "width": 21, + "height": 28, + "xoffset": -6, + "yoffset": 27, "xadvance": 9, "chnl": 15, - "x": 77, - "y": 458, + "x": 469, + "y": 254, "page": 0 }, { "id": 94, "index": 2604, "char": "^", - "width": 16, - "height": 15, - "xoffset": 0, - "yoffset": 10, + "width": 28, + "height": 27, + "xoffset": -6, + "yoffset": 4, "xadvance": 16, "chnl": 15, - "x": 122, - "y": 344, + "x": 469, + "y": 294, "page": 0 }, { "id": 176, "index": 2513, "char": "°", - "width": 16, - "height": 15, - "xoffset": -1, - "yoffset": 11, + "width": 28, + "height": 27, + "xoffset": -7, + "yoffset": 5, "xadvance": 14, "chnl": 15, - "x": 118, - "y": 402, + "x": 428, + "y": 301, "page": 0 }, { "id": 45, "index": 2435, "char": "-", - "width": 15, - "height": 8, - "xoffset": -1, - "yoffset": 26, + "width": 27, + "height": 20, + "xoffset": -7, + "yoffset": 20, "xadvance": 14, "chnl": 15, - "x": 53, - "y": 503, + "x": 468, + "y": 333, "page": 0 }, { "id": 96, "index": 2788, "char": "`", - "width": 9, - "height": 15, - "xoffset": -1, - "yoffset": 6, + "width": 21, + "height": 27, + "xoffset": -7, + "yoffset": 0, "xadvance": 9, "chnl": 15, - "x": 132, - "y": 384, + "x": 428, + "y": 340, "page": 0 }, { "id": 46, "index": 2396, "char": ".", - "width": 9, - "height": 8, - "xoffset": 0, - "yoffset": 33, + "width": 21, + "height": 20, + "xoffset": -6, + "yoffset": 27, "xadvance": 9, "chnl": 15, - "x": 49, - "y": 274, + "x": 387, + "y": 347, "page": 0 }, { @@ -1367,12 +1367,12 @@ "char": " ", "width": 0, "height": 0, - "xoffset": -2, - "yoffset": 37, + "xoffset": -8, + "yoffset": 31, "xadvance": 10, "chnl": 15, - "x": 8, - "y": 122, + "x": 132, + "y": 363, "page": 0 } ], @@ -1486,10 +1486,10 @@ "smooth": 1, "aa": 1, "padding": [ - 2, - 2, - 2, - 2 + 8, + 8, + 8, + 8 ], "spacing": [ 0, @@ -1511,7 +1511,7 @@ }, "distanceField": { "fieldType": "msdf", - "distanceRange": 4 + "distanceRange": 16 }, "kernings": [ { diff --git a/public/fonts/cormorant.png b/public/fonts/cormorant.png index 43f364d5..044bf296 100644 Binary files a/public/fonts/cormorant.png and b/public/fonts/cormorant.png differ diff --git a/src/@types/rendering/Label.d.ts b/src/@types/rendering/Label.d.ts index 836a4a61..ba7fb723 100644 --- a/src/@types/rendering/Label.d.ts +++ b/src/@types/rendering/Label.d.ts @@ -38,8 +38,35 @@ export type Label = { * producers have migrated to `worldEmMpc`. */ readonly pixelSize: number; - /** RGBA premultiplied, defaults to [1,1,1,1]. */ + /** + * Straight (non-premultiplied) RGBA fill colour, default `[1, 1, 1, 1]`. + * + * ## Convention + * + * Spell the colour the natural way — `[1, 0, 0, 0.5]` is + * "half-transparent red". The renderer's pack loop multiplies + * `rgb * a` on write before uploading to the GPU storage buffer; the + * fragment shader composites in premultiplied space. Producers + * therefore never have to think about premultiplication. + * + * The outline/glow colour fields below follow the same straight-RGBA + * convention — uniformity across the colour API surface is the whole + * point of carrying out this migration alongside the effects work. + */ readonly color?: Vec4; + /** + * Outside-stroke outline colour (straight RGBA — renderer + * premultiplies on write). Default `[0, 0, 0, 0]`, which combined + * with `outlineEmFrac = 0` collapses the outline band to zero + * contribution. Composited OVER the fill in premultiplied space. + */ + readonly outlineColor?: Vec4; + /** + * Outline width as a fraction of the projected em height. Default + * `0`. Em-fraction so the outline scales naturally with the label's + * perspective-driven sizing clamp. + */ + readonly outlineEmFrac?: number; /** * Floor clamp on the projected em height in screen pixels (default 8). * When the perspective projection of `worldEmMpc` falls below this diff --git a/src/components/DebugPanel/DebugPanel.tsx b/src/components/DebugPanel/DebugPanel.tsx index 50300470..07c498de 100644 --- a/src/components/DebugPanel/DebugPanel.tsx +++ b/src/components/DebugPanel/DebugPanel.tsx @@ -33,6 +33,7 @@ import { AssetLoadingSection } from './AssetLoadingSection'; import { GpuTimingsSection } from './GpuTimingsSection'; import { RenderTogglesSection } from './RenderTogglesSection'; import { DataQualitySection } from './DataQualitySection'; +import { LabelEffectsSection } from './LabelEffectsSection'; export type DebugPanelProps = { slots: ReadonlyMap>; @@ -82,6 +83,8 @@ export function DebugPanel({ onHighlightFallbackChange={onHighlightFallbackChange} onRealOnlyModeChange={onRealOnlyModeChange} /> +
+
); } diff --git a/src/components/DebugPanel/LabelEffectsSection.tsx b/src/components/DebugPanel/LabelEffectsSection.tsx new file mode 100644 index 00000000..7d6c164e --- /dev/null +++ b/src/components/DebugPanel/LabelEffectsSection.tsx @@ -0,0 +1,80 @@ +/** + * LabelEffectsSection — live-tuning controls for the label outline. + * + * Pick a target category, tune outline colour + width, then bake the + * values into `POI_STYLES.` or `youAreHereSubsystem.ts`. The + * override is a temporary hook, not a storage location. + * + * `setLabelStyleOverride` runs in `useEffect`, not during render — + * side effects during render trigger strict-mode double-fires. + */ + +import { useEffect, useState, type ReactElement } from 'react'; +import { + setLabelStyleOverride, + clearLabelStyleOverride, + type LabelStyleOverrideTarget, +} from '../../services/engine/labelStyleOverride'; +import type { Vec4 } from '../../@types/math/Vec4'; + +const CATEGORIES: readonly LabelStyleOverrideTarget[] = [ + 'youAreHere', + 'cluster', + 'supercluster', + 'famousGalaxy', + 'void', +]; + +function hexToRgb(hex: string): [number, number, number] { + const m = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!m) return [1, 1, 1]; + const n = parseInt(m[1]!, 16); + return [((n >> 16) & 0xff) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255]; +} + +export function LabelEffectsSection(): ReactElement { + const [target, setTarget] = useState(''); + const [outlineHex, setOutlineHex] = useState('#000000'); + const [outlineAlpha, setOutlineAlpha] = useState(0.1); + const [outlineEmFrac, setOutlineEmFrac] = useState(0.16); + + // Cleanup clears the override on unmount so closing the panel + // mid-tune restores producer-default styling. + useEffect(() => { + if (target === '') { + clearLabelStyleOverride(); + return; + } + const [or, og, ob] = hexToRgb(outlineHex); + const outlineColor: Vec4 = [or, og, ob, outlineAlpha]; + setLabelStyleOverride({ targetCategory: target, outlineColor, outlineEmFrac }); + return () => clearLabelStyleOverride(); + }, [target, outlineHex, outlineAlpha, outlineEmFrac]); + + const labelStyle = { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } as const; + return ( +
+ Label Effects +
+ + + +
+
+ ); +} diff --git a/src/data/fonts.ts b/src/data/fonts.ts index 27eeede0..ba48352f 100644 --- a/src/data/fonts.ts +++ b/src/data/fonts.ts @@ -53,14 +53,29 @@ export const ATLAS_PX = 512; /** * MSDF distance range in pixels. Controls how wide the signed-distance - * field around each glyph edge extends. The fragment shader's - * `fwidth`-based smoothstep band is exactly one pixel wide for any - * scale, regardless of this value — but a too-small range produces - * visible banding at extreme upscales and a too-large range wastes - * atlas pixels. 4 is the msdf-bmfont-xml default and reads cleanly - * from 12 px (`Label.minPixelSize`) up to 64 px (`maxPixelSize`). + * field around each glyph edge extends, i.e. the maximum off-edge + * distance the atlas can faithfully encode. The body-fill fragment + * shader's `fwidth`-based smoothstep band is exactly one pixel wide + * for any scale regardless of this value — but outline and glow + * effects sample the SDF *past* the glyph contour, and any distance + * beyond `±DISTANCE_RANGE_PX / 2` clamps at the texel boundary, + * cutting off the falloff tail. + * + * 16 is sized for the per-label outline + glow pass. Glow extents + * scale with `maxPixelSize` (60 px) and reach ~12 px past the glyph + * edge in the worst case; add ~2 px of outline and we need at least + * 14 px of encoded headroom on either side. Choosing 16 (so ±8 px + * on each side) keeps ~25% margin past the worst-case effect extent + * while still fitting the 95-glyph charset into the 512² atlas. + * + * The previous value 4 (msdf-bmfont-xml's default) was sized only + * for the smoothstep body-fill band and clamped the soft glow tail + * to a hard step a couple of pixels past the contour. Shader-side + * SDF-units math (e.g. `widthInSdfUnits = (emFrac * ATLAS_EM_PX) / + * DISTANCE_RANGE_PX`) bakes this constant in too, so changing it + * requires regenerating the atlas via `npm run build-fonts`. */ -export const DISTANCE_RANGE_PX = 4; +export const DISTANCE_RANGE_PX = 16; /** * Em-size of glyphs in atlas pixels at the source SDF resolution. diff --git a/src/services/engine/engine.ts b/src/services/engine/engine.ts index 1d89ee6b..15785255 100644 --- a/src/services/engine/engine.ts +++ b/src/services/engine/engine.ts @@ -113,6 +113,7 @@ import { createSelectionSubsystem } from './subsystems/selectionSubsystem'; import { createBiasCorrectionSubsystem } from './subsystems/biasCorrectionSubsystem'; import { createYouAreHereSubsystem } from './subsystems/youAreHereSubsystem'; import { createLabelDirectorSubsystem } from './subsystems/labelDirectorSubsystem'; +import { registerLabelStyleOverrideWake } from './labelStyleOverride'; import { createPoiSubsystem } from './subsystems/poiSubsystem'; import { createFpsCounter } from './subsystems/fpsCounter'; import { HDR_PASSES, UI_PASSES } from './frame/passes'; @@ -698,6 +699,16 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En state.subsystems.labelDirector.registerProducer(state.subsystems.youAreHere); state.subsystems.labelDirector.registerProducer(state.subsystems.pois); + // ── Wake on label-style override edits ──────────────────────────────── + // + // The DebugPanel's LabelEffectsSection writes to `labelStyleOverride`, + // which bumps a version counter that the label director reads from its + // signature hash. But render-on-demand only consults that hash inside + // an active frame — slider edits at idle would sit invisible until the + // user nudged the camera. Registering scheduler.requestRender here + // closes the loop: every set/clear wakes the loop on the next tick. + registerLabelStyleOverrideWake(() => state.subsystems.scheduler.requestRender()); + // ── Cleanup function returned by `attachOrbitControls` ───────────────── // Orbit-controls attachment lives outside `inputBindings` because it // needs a fully-constructed OrbitCamera which doesn't exist at diff --git a/src/services/engine/labelStyleOverride.ts b/src/services/engine/labelStyleOverride.ts new file mode 100644 index 00000000..e08fbe87 --- /dev/null +++ b/src/services/engine/labelStyleOverride.ts @@ -0,0 +1,108 @@ +/** + * labelStyleOverride — process-wide, single-slot live-tuning hook for + * the DebugPanel's LabelEffectsSection. + * + * ### Why module-scoped mutable state? + * + * The override is a developer-only debug hook: while the DebugPanel + * has it on, every label-emitting subsystem consults the current + * value at frame-build time and substitutes the override's outline + + * glow fields for its own producer defaults. React state in the + * panel component is the wrong shape because the engine's per-frame + * code runs outside React's render loop and would need a ref or + * useEffect to read the current values; a plain module-scoped object + * is read directly by every producer with zero ceremony. + * + * ### Why a single slot, not a per-category record? + * + * The workflow is "select category, tune, bake into POI_STYLES, move + * to next category". A per-category record would invite the user to + * leave overrides stale across category switches; the single slot + * makes the active target unambiguous. + * + * ### Why default targetCategory = null? + * + * Production startup should never accidentally apply an override. + * The DebugPanel only exists in DEV builds or when ?debug is in the + * URL, so a non-DEV runtime never even calls `setLabelStyleOverride`. + * Defaulting to null means "no producer matches" and the override is + * completely inert until a developer opens the panel and picks a + * category. + */ + +import type { Vec4 } from '../../@types/math/Vec4'; +import type { PoiCategory } from './subsystems/poiSubsystem'; + +/** + * The set of label-emitting categories the override can target. + * Mirrors the dropdown in `LabelEffectsSection.tsx` — keep in sync. + */ +export type LabelStyleOverrideTarget = 'youAreHere' | PoiCategory; + +/** + * Read-only snapshot of the current override. `targetCategory` is + * null when the override is inactive. + */ +export type LabelStyleOverride = { + readonly targetCategory: LabelStyleOverrideTarget | null; + readonly outlineColor: Vec4; + readonly outlineEmFrac: number; +}; + +// The single mutable slot. Reassigned (not mutated in place) by +// `setLabelStyleOverride` so any consumer that captured the prior +// reference sees a stable snapshot for the duration of one frame. +let current: LabelStyleOverride = { + targetCategory: null, + outlineColor: [0, 0, 0, 0], + outlineEmFrac: 0, +}; + +// Monotonic version counter — incremented on every set/clear. The +// label director includes this in its signature hash so an override +// edit triggers a re-flush even when the merged label set is +// id+fadeAlpha-stable. Cheaper than a listener channel and impossible +// to leak (no subscribers to forget to dispose). +let version = 0; + +// Wake callback — the engine bootstrap registers a closure that calls +// `scheduler.requestRender()`. Without this, the version bump only +// causes a re-flush IF a frame happens to run; render-on-demand sits +// idle until the user nudges the mouse. Registration is module-scoped +// because the override has no constructor seam to receive deps. +let wake: (() => void) | null = null; + +export function getLabelStyleOverride(): LabelStyleOverride { + return current; +} + +export function getLabelStyleOverrideVersion(): number { + return version; +} + +export function setLabelStyleOverride(next: LabelStyleOverride): void { + current = next; + version++; + wake?.(); +} + +export function clearLabelStyleOverride(): void { + current = { + targetCategory: null, + outlineColor: [0, 0, 0, 0], + outlineEmFrac: 0, + }; + version++; + wake?.(); +} + +/** + * Register a wake callback fired on every override set/clear. The + * engine's bootstrap wires this to `scheduler.requestRender()` so a + * DebugPanel slider edit wakes the render-on-demand loop in addition + * to bumping the director's signature hash. Tests can leave this + * unregistered — the version counter still works. + */ +export function registerLabelStyleOverrideWake(fn: () => void): void { + wake = fn; +} diff --git a/src/services/engine/subsystems/labelDirectorSubsystem.ts b/src/services/engine/subsystems/labelDirectorSubsystem.ts index eba3d088..827fe3b3 100644 --- a/src/services/engine/subsystems/labelDirectorSubsystem.ts +++ b/src/services/engine/subsystems/labelDirectorSubsystem.ts @@ -41,6 +41,7 @@ import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { Destroyable } from '../../../@types/rendering/Destroyable'; import type { LabelProducer } from '../../../@types/engine/subsystems/LabelProducer'; import type { LabelDirectorSubsystem } from '../../../@types/engine/subsystems/LabelDirectorSubsystem'; +import { getLabelStyleOverrideVersion } from '../labelStyleOverride'; export function createLabelDirectorSubsystem(): LabelDirectorSubsystem { let labelRenderer: LabelRenderer | null = null; @@ -64,7 +65,10 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem { } function signatureOf(labels: readonly Label[], lines: readonly MarkerLine[]): string { - // Cheap stable signature: per-entry `id:fadeAlpha`, joined. + // Cheap stable signature: per-entry `id:fadeAlpha`, joined, plus a + // trailing `;O:` term that tracks the labelStyleOverride + // module's monotonic version counter. + // // Re-upload triggers when ids/count change OR when any entry's // `fadeAlpha` differs from the prior frame. Including `fadeAlpha` // matters because the `youAreHereSubsystem` keeps the same `id` @@ -75,6 +79,15 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem { // appears at e.g. 0.1 alpha and never brightens as the camera // closes in.) // + // The override-version term forces a re-flush whenever the + // DebugPanel's LabelEffectsSection mutates `labelStyleOverride`. + // Producers consult the override at frame-build time to swap in + // outline+glow fields, but the producer's resulting Label objects + // still carry the same `id` and `fadeAlpha`, so without this term + // the director would short-circuit and a slider edit would have no + // visible effect until something else (camera motion, fade) bumped + // the signature. + // // We deliberately DON'T include world positions or colours — the // glyph layout in `labelRenderer.setLabels` is the expensive // step we're protecting; static-position producers (youAreHere, @@ -86,7 +99,7 @@ export function createLabelDirectorSubsystem(): LabelDirectorSubsystem { // from POI name which is part of the id space. const lIds = labels.map((l) => `${l.id}:${l.fadeAlpha ?? 1}`).join('|'); const mIds = lines.map((m) => `${m.id}:${m.fadeAlpha ?? 1}`).join('|'); - return `L:${labels.length}:${lIds};M:${lines.length}:${mIds}`; + return `L:${labels.length}:${lIds};M:${lines.length}:${mIds};O:${getLabelStyleOverrideVersion()}`; } function runFrame(state: EngineState, ctx: ReadyFrameContext): void { diff --git a/src/services/engine/subsystems/poiSubsystem.ts b/src/services/engine/subsystems/poiSubsystem.ts index 30918413..42bdc0c0 100644 --- a/src/services/engine/subsystems/poiSubsystem.ts +++ b/src/services/engine/subsystems/poiSubsystem.ts @@ -102,6 +102,7 @@ import type { ClusterMarkerDescriptor } from '../../../@types/rendering/ClusterM import { apparentSizePx } from '../../../utils/math/apparentSizePx'; import { hexToGl } from '../../../utils/color/hexToGl'; import { FADE_IN_DURATION_MS } from '../../animation/fadeController'; +import { getLabelStyleOverride } from '../labelStyleOverride'; type CategoryStyle = { readonly labelColor: Vec4; @@ -166,6 +167,10 @@ type CategoryStyle = { readonly markerMaxApparentRadiusPx: number; /** Smoothstep band width for the marker fade-out. */ readonly markerMaxApparentFadeBandPx: number; + /** Drop-shadow outline (straight RGBA — renderer premultiplies). */ + readonly outlineColor: Vec4; + /** Outline width as em-fraction. Capped at ~0.28 by atlas padding. */ + readonly outlineEmFrac: number; }; /** @@ -201,6 +206,8 @@ export const POI_STYLES = { ringColor: hexToGl('#B39947'), markerMaxApparentRadiusPx: 700, markerMaxApparentFadeBandPx: 400, + outlineColor: [0, 0, 0, 0.1], + outlineEmFrac: 0.16, }, supercluster: { labelColor: hexToGl('#FFCC80'), @@ -216,6 +223,8 @@ export const POI_STYLES = { ringColor: hexToGl('#996B3666'), markerMaxApparentRadiusPx: 700, markerMaxApparentFadeBandPx: 400, + outlineColor: [0, 0, 0, 0.1], + outlineEmFrac: 0.16, }, famousGalaxy: { labelColor: hexToGl('#FFF2CC'), @@ -232,6 +241,8 @@ export const POI_STYLES = { ringColor: hexToGl('#000000'), markerMaxApparentRadiusPx: 700, markerMaxApparentFadeBandPx: 400, + outlineColor: [0, 0, 0, 0.1], + outlineEmFrac: 0.16, }, void: { labelColor: hexToGl('#99D9F2'), @@ -249,6 +260,8 @@ export const POI_STYLES = { ringColor: hexToGl('#73B3D9'), markerMaxApparentRadiusPx: 700, markerMaxApparentFadeBandPx: 400, + outlineColor: [0, 0, 0, 0.1], + outlineEmFrac: 0.16, }, } as const satisfies Readonly>; @@ -407,6 +420,12 @@ export function createPoiSubsystem(input: CreatePoiSubsystemInput = {}): PoiSubs const halfH = ctx.canvasSize.height * 0.5; const fovYRad = 2 * Math.atan(halfH / ctx.drawPxPerRad); const [cx, cy, cz] = ctx.drawCamPos; + // Capture the live-tuning override once per frame — reads are + // cheap, but a consistent snapshot matters when the loop crosses + // many POIs. The director will not call produceLabels again + // within the same frame. See `labelStyleOverride.ts` for the + // module-scoped state's rationale. + const override = getLabelStyleOverride(); for (const p of pois) { // Label-axis gate. Markers consult their own `markerVisibility` // record in `produceMarkers` below — flipping a category's label @@ -535,6 +554,17 @@ export function createPoiSubsystem(input: CreatePoiSubsystemInput = {}): PoiSubs } } + // Per-POI override fields: only POIs whose own category matches + // the override's target adopt the outline values; other + // categories keep their category-default outline. + const overrideFields = + override.targetCategory === p.category + ? { + outlineColor: override.outlineColor, + outlineEmFrac: override.outlineEmFrac, + } + : {}; + labels.push({ id: p.id, worldPos: labelWorldPos, @@ -548,6 +578,9 @@ export function createPoiSubsystem(input: CreatePoiSubsystemInput = {}): PoiSubs fadeAlpha, alignX, alignY, + outlineColor: [...style.outlineColor], + outlineEmFrac: style.outlineEmFrac, + ...overrideFields, }); } // One-shot layer fade-in: first frame that emits a non-empty diff --git a/src/services/engine/subsystems/youAreHereSubsystem.ts b/src/services/engine/subsystems/youAreHereSubsystem.ts index 78473561..9463800b 100644 --- a/src/services/engine/subsystems/youAreHereSubsystem.ts +++ b/src/services/engine/subsystems/youAreHereSubsystem.ts @@ -33,6 +33,7 @@ import type { LabelProducerOutput } from '../../../@types/engine/subsystems/Labe import type { YouAreHereSubsystem } from '../../../@types/engine/subsystems/YouAreHereSubsystem'; import { youAreHereAlpha } from '../../gpu/labels/youAreHereVisibility'; import { FADE_IN_DURATION_MS } from '../../animation/fadeController'; +import { getLabelStyleOverride } from '../labelStyleOverride'; const LABEL_TEXT = 'You are here'; const LABEL_ANCHOR_MPC = 0.05; @@ -44,6 +45,10 @@ const LINE_TOP_MPC = LABEL_ANCHOR_MPC * 0.75; // `[1, 1, 1, 1]` is display white at any tone-map setting. const LABEL_COLOR: Vec4 = [1, 1, 1, 1]; const LINE_COLOR: Vec4 = [1, 1, 1, 1]; +// Soft black drop-shadow for legibility against the starfield. +// Re-tune via DebugPanel `LabelEffectsSection`. +const OUTLINE_COLOR: Vec4 = [0, 0, 0, 0.1]; +const OUTLINE_EM_FRAC = 0.16; export function createYouAreHereSubsystem(): YouAreHereSubsystem { // One-shot fade-in: the first frame where this producer emits a @@ -68,6 +73,19 @@ export function createYouAreHereSubsystem(): YouAreHereSubsystem { ); } + // Live-tuning override: when the DebugPanel selects 'youAreHere' + // as the target category, substitute the override's outline fields + // for the producer defaults. Read fresh each frame so panel + // changes apply on the next render. + const override = getLabelStyleOverride(); + const effectFields = + override.targetCategory === 'youAreHere' + ? { + outlineColor: override.outlineColor, + outlineEmFrac: override.outlineEmFrac, + } + : {}; + const labels: readonly Label[] = [ { id: 'you-are-here', @@ -81,6 +99,9 @@ export function createYouAreHereSubsystem(): YouAreHereSubsystem { maxPixelSize: 150, fadeAlpha: alpha, alignX: 'center', + outlineColor: [...OUTLINE_COLOR], + outlineEmFrac: OUTLINE_EM_FRAC, + ...effectFields, }, ]; const lines: readonly MarkerLine[] = [ diff --git a/src/services/gpu/renderers/labelRenderer.ts b/src/services/gpu/renderers/labelRenderer.ts index b737fd73..2e49ef4c 100644 --- a/src/services/gpu/renderers/labelRenderer.ts +++ b/src/services/gpu/renderers/labelRenderer.ts @@ -88,12 +88,18 @@ const UNIFORM_BYTES = 80; /** * Per-label storage buffer stride, matching `struct LabelData` in io.wesl: - * worldPos vec4 — xyz = world Mpc, w = worldEmMpc - * color vec4 — premultiplied rgb, a - * sizing vec4 — pixelSize, minPixelSize, maxPixelSize, fadeAlpha - * 3 × 16 bytes = 48 bytes/label. + * + * bytes 0..15 worldPos vec4 — xyz = world Mpc, w = worldEmMpc + * bytes 16..31 color vec4 — premultiplied rgba (fill) + * bytes 32..47 sizing vec4 — outlineEmFrac, minPx, maxPx, fadeAlpha + * bytes 48..63 outlineColor vec4 — premultiplied rgba (outline stroke) + * + * 4 × 16 bytes = 64 bytes/label. `sizing.x` repurposes the legacy + * `pixelSize` slot (ignored by the shader since the worldEmMpc + * migration) to carry `outlineEmFrac`, sparing a fresh vec4 for one + * scalar. */ -const LABEL_DATA_BYTES = 48; +const LABEL_DATA_BYTES = 64; /** * Per-glyph instance buffer stride, matching `VsIn` attributes 1–5 in io.wesl: @@ -414,7 +420,7 @@ export function createLabelRenderer( label.alignY ?? 'baseline', ); - // Write per-label storage record (48 bytes, 12 floats) unconditionally + // Write per-label storage record (96 bytes, 24 floats) unconditionally // — even when `quads` is empty. Keeping the per-label index stable // across the outer loop matters because each glyph carries its // labelIndex by position; if we skipped a label whose text produced @@ -422,24 +428,41 @@ export function createLabelRenderer( // label entry. An unused storage slot is harmless (no glyph // references it, the GPU never reads it). // - // [0..3] worldPos (x,y,z, worldEmMpc) - // [4..7] color (r,g,b,a premultiplied) - // [8..11] sizing (pixelSize, minPx, maxPx, fadeAlpha) + // [0..3] worldPos (x, y, z, worldEmMpc) + // [4..7] color (r*a, g*a, b*a, a — premultiplied) + // [8..11] sizing (outlineEmFrac, minPx, maxPx, fadeAlpha) + // [12..15] outlineColor (r*a, g*a, b*a, a) const labelBase = li * (LABEL_DATA_BYTES / 4); labelBuf[labelBase + 0] = label.worldPos[0]; labelBuf[labelBase + 1] = label.worldPos[1]; labelBuf[labelBase + 2] = label.worldPos[2]; labelBuf[labelBase + 3] = label.worldEmMpc ?? 0.01; + + // Public colour API is STRAIGHT RGBA — producers write the natural + // form (`[1, 0, 0, 0.5]` is "half-transparent red"); the fragment + // shader composites in premultiplied space, so we multiply r/g/b + // by a here on the write boundary. const color = label.color ?? [1, 1, 1, 1]; - labelBuf[labelBase + 4] = color[0]!; - labelBuf[labelBase + 5] = color[1]!; - labelBuf[labelBase + 6] = color[2]!; - labelBuf[labelBase + 7] = color[3]!; - labelBuf[labelBase + 8] = label.pixelSize; + const ca = color[3]!; + labelBuf[labelBase + 4] = color[0]! * ca; + labelBuf[labelBase + 5] = color[1]! * ca; + labelBuf[labelBase + 6] = color[2]! * ca; + labelBuf[labelBase + 7] = ca; + + labelBuf[labelBase + 8] = label.outlineEmFrac ?? 0; labelBuf[labelBase + 9] = label.minPixelSize ?? 8; labelBuf[labelBase + 10] = label.maxPixelSize ?? 64; labelBuf[labelBase + 11] = label.fadeAlpha ?? 1; + // outline colour — same straight → premultiplied conversion as fill. + // Default [0,0,0,0] makes outlineEmFrac irrelevant (band alpha is 0). + const outlineColor = label.outlineColor ?? [0, 0, 0, 0]; + const oa = outlineColor[3]!; + labelBuf[labelBase + 12] = outlineColor[0]! * oa; + labelBuf[labelBase + 13] = outlineColor[1]! * oa; + labelBuf[labelBase + 14] = outlineColor[2]! * oa; + labelBuf[labelBase + 15] = oa; + // Resolve the label's font to its GPU texture-array layer index // ONCE per label, outside the inner glyph loop — every glyph in // a label shares the same layer. @@ -556,5 +579,10 @@ export function createLabelRenderer( // `satisfies Renderer` confirms the shared label+destroy contract at // compile time without widening the static type seen by consumers. renderer satisfies Renderer; + // Expose the CPU-side label storage scratch buffer for unit tests + // that need to assert pack-loop output. The accessor is prefixed + // with `__debug` to flag it as test-only — production code should + // never read this; the GPU has the authoritative copy. + (renderer as unknown as { __debugLabelBuf: () => Float32Array }).__debugLabelBuf = () => labelBuf; return renderer; } diff --git a/src/services/gpu/shaders/labels/fragment.wesl b/src/services/gpu/shaders/labels/fragment.wesl index 1ef2616a..722ce7d8 100644 --- a/src/services/gpu/shaders/labels/fragment.wesl +++ b/src/services/gpu/shaders/labels/fragment.wesl @@ -1,46 +1,72 @@ -// labels/fragment.wesl — MSDF labels fragment stage. +// labels/fragment.wesl — MSDF labels fragment stage with two-band +// composite: outline (hard outside stroke) over fill (glyph body), all +// in premultiplied space. // -// Samples the multi-channel signed-distance atlas, takes the median -// across R/G/B to recover the glyph edge, then anti-aliases the edge -// with a screen-space-derivative width. Output is premultiplied — the -// blend state on the renderer side expects (rgb*a, a). +// ## Bands // -// ## Multi-font sampling +// The MSDF distance 'd' is positive inside the glyph and negative +// outside; the glyph contour sits at 'd = 0'. // -// The atlas binding is a texture_2d_array: each registered font in -// src/data/fonts.ts occupies one array layer. The per-glyph -// fontIndex varying (flat-interpolated from the vertex stage) selects -// the layer at sample time, so a single draw call can render glyphs -// from any mix of registered fonts — the GPU picks the right atlas -// page per fragment. +// fill : smoothstep(-aa, +aa, d) +// — one-pixel AA band straddling the contour. +// outline: smoothstep(-aa - outlineSdf, -aa, d) * (1 - fill) +// — covers d ∈ [-outlineSdf, 0]; the (1 - fill) factor +// masks the inside half so the outline doesn't bleed +// into the glyph body. +// +// 'outlineSdf' is in SDF units (the same units 'd' lives in); the +// vertex stage converts from em-fraction via +// widthInSdfUnits = (outlineEmFrac * ATLAS_EM_PX) / DISTANCE_RANGE_PX +// +// ## Composite +// +// out = over(outline, fill) +// +// Both band colours arrive pre-multiplied and pre-faded by fadeAlpha +// (baked at the vertex stage), so the OVER step is the canonical +// over(A, B) = A + B * (1 - A.a) import package::labels::io::VsOut; +// The atlas binding is a texture_2d_array: each registered font in +// src/data/fonts.ts occupies one array layer. The per-glyph fontIndex +// varying (flat-interpolated from the vertex stage) selects the layer +// at sample time, so a single draw call can render glyphs from any mix +// of registered fonts — the GPU picks the right atlas page per fragment. @group(0) @binding(2) var atlas: texture_2d_array; @group(0) @binding(3) var atlasSampler: sampler; -// Median of three scalars. The MSDF technique encodes one SDF per +// Median of three scalars. The MSDF technique encodes one SDF per // colour channel, each shifted by a sub-pixel; taking the median of // the three recovers the glyph contour even where one channel -// disagrees (sharp corners, near-zero strokes). It's the 'M' in MSDF. +// disagrees (sharp corners, near-zero strokes). It's the 'M' in MSDF. fn median3(r: f32, g: f32, b: f32) -> f32 { return max(min(r, g), min(max(r, g), b)); } @fragment fn fs(input: VsOut) -> @location(0) vec4 { - // textureSample on a 2D-array takes the layer index as the third - // argument (after the UV). i32 cast because WGSL's texture-array - // sample signature requires a signed integer layer. let s = textureSample(atlas, atlasSampler, input.uv, i32(input.fontIndex)).rgb; - // Distance from the glyph contour: positive inside, negative outside, - // zero exactly on the edge. let d = median3(s.r, s.g, s.b) - 0.5; - // 'fwidth' gives roughly one pixel's worth of distance at this - // fragment's scale, so the smoothstep band is exactly one pixel - // wide regardless of zoom — that's what keeps MSDF text crisp at - // any size. let aa = fwidth(d); - let alpha = smoothstep(-aa, aa, d) * input.color.a; - return vec4(input.color.rgb * alpha, alpha); + + let outlineSdf = input.outlineSdf; + + // Fill mask (1 inside the glyph, 0 outside, smoothed at the edge). + let fillMask = smoothstep(-aa, aa, d); + + // Outline mask covers d ∈ [-outlineSdf, 0] but is masked OUT inside + // the glyph. When outlineSdf == 0 the smoothstep collapses to a + // single-point step at d=0 — the mask is then identically zero + // outside the glyph (no contribution) and cancelled by (1 - fillMask) + // inside. So a zero outline width contributes nothing. + let outlineBand = smoothstep(-aa - outlineSdf, -aa, d); + let outlineMask = outlineBand * (1.0 - fillMask); + + // Per-band premultiplied colours. + let fillPM = input.color * fillMask; + let outlinePM = input.outlineColor * outlineMask; + + // Composite over(outline, fill): result = top + bottom * (1 - top.a) + return outlinePM + fillPM * (1.0 - outlinePM.a); } diff --git a/src/services/gpu/shaders/labels/io.wesl b/src/services/gpu/shaders/labels/io.wesl index 54a33b22..56b94ce1 100644 --- a/src/services/gpu/shaders/labels/io.wesl +++ b/src/services/gpu/shaders/labels/io.wesl @@ -50,16 +50,20 @@ struct Uniforms { }; struct LabelData { - // worldPos.xyz = anchor in Mpc; worldPos.w = worldEmMpc (PRIMARY size driver — - // em height in world-space Mpc, projected to pixels by the vertex stage). + // worldPos.xyz = anchor in Mpc; worldPos.w = worldEmMpc (PRIMARY size + // driver — em height in world-space Mpc, projected to pixels by the + // vertex stage). worldPos: vec4, - // color.rgb premultiplied; color.a = base alpha (multiplied by fadeAlpha). + // Fill colour — premultiplied rgba. The renderer pack loop multiplies + // r/g/b by a before upload, so the shader can treat (rgb, a) as the + // OVER-composite input directly. color: vec4, - // x = pixelSize (LEGACY — ignored by shader; kept for layout stability) - // y = minPixelSize (floor clamp on projected em height, in screen px) - // z = maxPixelSize (ceiling clamp on projected em height, in screen px) - // w = fadeAlpha + // x = outlineEmFrac (repurposed legacy pixelSize slot) + // y = minPixelSize, z = maxPixelSize, w = fadeAlpha sizing: vec4, + // Outside-stroke outline colour — premultiplied rgba. Zero alpha + // collapses the outline band regardless of outlineEmFrac. + outlineColor: vec4, }; struct VsIn { @@ -86,9 +90,18 @@ struct VsIn { struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, + // Fill colour pre-multiplied by fadeAlpha so the fragment stage can + // alpha-blend without re-reading fadeAlpha. @location(1) color: vec4, // Flat-interpolated so the fragment shader sees the same integer // layer index for every fragment of one glyph quad. Non-flat // interpolation of an integer is a WGSL validation error. @location(2) @interpolate(flat) fontIndex: u32, + // Outline colour pre-multiplied by fadeAlpha (same pre-fade bake as + // the fill colour — keeps the fragment stage simple). + @location(3) outlineColor: vec4, + // Outline width in SDF units (atlas px / DISTANCE_RANGE_PX). + // Converted CPU-side at the vertex stage so the fragment shader has + // the value ready without re-deriving from per-label data. + @location(4) outlineSdf: f32, }; diff --git a/src/services/gpu/shaders/labels/vertex.wesl b/src/services/gpu/shaders/labels/vertex.wesl index f8ec7a9b..d6597b80 100644 --- a/src/services/gpu/shaders/labels/vertex.wesl +++ b/src/services/gpu/shaders/labels/vertex.wesl @@ -5,9 +5,16 @@ // is the em height expressed in Mpc of world space; the vertex stage // projects it through the camera's clip.w to obtain a screen-pixel // height, clamps to [minPx, maxPx] for legibility, and scales the -// atlas-baked glyph quad to match. Labels therefore grow as the -// camera approaches (natural perspective), bounded below and above. -// See labels/io.wesl's docblock for the full sizing model. +// atlas-baked glyph quad to match. +// +// ## Quad expansion for the outline fringe +// +// When a label sets 'outlineEmFrac', the on-screen footprint extends +// past the atlas rect by 'outlineEmFrac * displayEmPx' screen pixels. +// If the quad stays sized to the atlas rect, the outline clips at the +// glyph's bounding box. We grow each corner outward by the fringe +// expressed in atlas px; the UV expansion below extrapolates past the +// glyph's atlas rect so the fragment shader can sample the SDF tail. import package::labels::io::Uniforms; import package::labels::io::LabelData; @@ -18,11 +25,11 @@ import package::lib::billboard::worldLenToPx; import package::lib::billboard::pxToClipOffset; // Atlas em pixel size — must match 'ATLAS_FONT_SIZE' in src/data/fonts.ts. -// Hardcoded rather than passed as a uniform because it's a build-time -// constant of the atlas binary, not a per-frame value. If the atlas -// is ever rebuilt at a different size, update both this constant and -// the fonts.ts export in the same commit. const ATLAS_EM_PX: f32 = 42.0; +// MSDF distance range in pixels — must match 'DISTANCE_RANGE_PX' in +// src/data/fonts.ts. Sets the SDF's encoded headroom past the glyph +// contour; tighter ranges clamp the tail and produce banded edges. +const DISTANCE_RANGE_PX: f32 = 16.0; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var labels: array; @@ -30,56 +37,67 @@ const ATLAS_EM_PX: f32 = 42.0; @vertex fn vs(input: VsIn) -> VsOut { let label = labels[input.labelIndex]; - let worldPos = label.worldPos.xyz; - let worldEmMpc = label.worldPos.w; // primary size driver — world-space em height in Mpc - // sizing.x (pixelSize) is a legacy buffer slot, kept for layout stability. - // The new model reads worldEmMpc instead; sizing.x is intentionally ignored. - let minPx = label.sizing.y; - let maxPx = label.sizing.z; - let fadeAlpha = label.sizing.w; + let worldPos = label.worldPos.xyz; + let worldEmMpc = label.worldPos.w; + let outlineEmFrac = label.sizing.x; + let minPx = label.sizing.y; + let maxPx = label.sizing.z; + let fadeAlpha = label.sizing.w; // Project anchor to clip space. let clip = worldToClip(u.cam, worldPos); - // Perspective-driven size: project 'worldEmMpc' (an em height in Mpc - // of world space) into screen pixels at the anchor's depth. The - // shared 'worldLenToPx' helper encapsulates the standard - // perspective NDC→screen projection ((L / clip.w) * viewport.y / 2); - // see lib/billboard.wesl for the canonical derivation. Clamping to - // [minPx, maxPx] is label-specific — bounds the result for - // legibility at extreme distances and very close approach — so it - // stays inline here. + // Perspective-driven size. let pxPerEm = worldLenToPx(u.cam, worldEmMpc, clip.w); let displayEmPx = clamp(pxPerEm, minPx, maxPx); let pxScale = displayEmPx / ATLAS_EM_PX; - // Glyph corner in atlas px, relative to label anchor. Atlas Y is - // top-down; we flip to make Y up in world space (so labels appear - // above the anchor when localOffsetY is negative). + // Outline fringe in atlas px. The on-screen fringe is + // 'outlineEmFrac * displayEmPx' screen pixels, which divides by + // pxScale to recover atlas-px (because the same pxScale is applied + // to the corner offset below). + let fringeAtlasPx = outlineEmFrac * ATLAS_EM_PX; + + // Expand each corner outward by the fringe. The unit-corner attribute + // sits in {(0,0),(1,0),(0,1),(1,1)}; remap to {-1,+1} via (corner*2-1) + // to get an outward direction, then add 'corner*localSize' for the + // glyph rect, and 'direction*fringeAtlasPx' for the fringe extension. + let outward = input.corner * 2.0 - vec2(1.0, 1.0); let cornerAtlasPx = vec2( - input.localOffset.x + input.corner.x * input.localSize.x, - -(input.localOffset.y + input.corner.y * input.localSize.y), + input.localOffset.x + input.corner.x * input.localSize.x + outward.x * fringeAtlasPx, + -(input.localOffset.y + input.corner.y * input.localSize.y + outward.y * fringeAtlasPx), ); - // Convert atlas px to clip space at depth clip.w using the shared - // 'pxToClipOffset' helper — it applies the (2 / viewport) NDC step - // and multiplies by clip.w so the GPU's perspective divide recovers - // the right pixel size. See lib/billboard.wesl for the derivation. let ndcOffset = pxToClipOffset(u.cam, cornerAtlasPx * pxScale, clip.w); - let outPos = vec4(clip.x + ndcOffset.x, clip.y + ndcOffset.y, clip.z, clip.w); - let uv = vec2( + // UV expansion to match the corner expansion. The UV rect coordinates + // are already normalised; the fringe in atlas px maps to UV space via + // '(uvSpan / localSize)'. The result samples outside the glyph's + // atlas rect at the fringe — the distance-only region past the glyph + // contour, which is where the outline band lives. + let uvSpanX = input.uvRect.z - input.uvRect.x; + let uvSpanY = input.uvRect.w - input.uvRect.y; + let uvBase = vec2( mix(input.uvRect.x, input.uvRect.z, input.corner.x), mix(input.uvRect.y, input.uvRect.w, input.corner.y), ); + let uvFringe = vec2( + outward.x * fringeAtlasPx * uvSpanX / max(input.localSize.x, 0.0001), + outward.y * fringeAtlasPx * uvSpanY / max(input.localSize.y, 0.0001), + ); + let uv = uvBase + uvFringe; + + // Pre-bake fadeAlpha into every colour channel that flows to the + // fragment stage. The fragment stage then just multiplies by the + // per-band SDF coverage, so fadeAlpha applies uniformly to fill and + // outline without an extra fragment-side multiply. + let outColor = vec4(label.color.rgb * fadeAlpha, label.color.a * fadeAlpha); + let outOutlineColor = vec4(label.outlineColor.rgb * fadeAlpha, label.outlineColor.a * fadeAlpha); + + // Convert em-fraction outline width to SDF units: + // widthInSdfUnits = (frac * ATLAS_EM_PX) / DISTANCE_RANGE_PX + let outlineSdf = outlineEmFrac * ATLAS_EM_PX / DISTANCE_RANGE_PX; - let outColor = vec4(label.color.rgb, label.color.a * fadeAlpha); - // fontIndex flows from the per-glyph instance attribute to a flat - // varying so the fragment shader samples the right atlas layer for - // this glyph's font. All glyphs of one label carry the same - // fontIndex (resolved CPU-side from label.font), but the value is - // per-glyph because that's the only attribute channel the per-glyph - // instance buffer exposes. - return VsOut(outPos, uv, outColor, input.fontIndex); + return VsOut(outPos, uv, outColor, input.fontIndex, outOutlineColor, outlineSdf); } diff --git a/tests/data/fonts.test.ts b/tests/data/fonts.test.ts index 7b0ab290..68c2e9c8 100644 --- a/tests/data/fonts.test.ts +++ b/tests/data/fonts.test.ts @@ -14,7 +14,7 @@ describe('font registry', () => { // and the runtime (loadFontAtlases.ts). Hard-coding them in two // places was the original sin the registry eliminates. expect(ATLAS_PX).toBe(512); - expect(DISTANCE_RANGE_PX).toBe(4); + expect(DISTANCE_RANGE_PX).toBe(16); expect(ATLAS_FONT_SIZE).toBe(42); }); diff --git a/tests/rendering/labelTypeFields.test.ts b/tests/rendering/labelTypeFields.test.ts new file mode 100644 index 00000000..776f6bb6 --- /dev/null +++ b/tests/rendering/labelTypeFields.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import type { Label } from '../../src/@types/rendering/Label'; +import type { Vec4 } from '../../src/@types/math/Vec4'; + +describe('Label type effect fields', () => { + it('declares optional outlineColor / outlineEmFrac', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/tests/services/engine/labelStyleOverride.test.ts b/tests/services/engine/labelStyleOverride.test.ts new file mode 100644 index 00000000..7efd86df --- /dev/null +++ b/tests/services/engine/labelStyleOverride.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getLabelStyleOverride, + setLabelStyleOverride, + clearLabelStyleOverride, + registerLabelStyleOverrideWake, + type LabelStyleOverrideTarget, +} from '../../../src/services/engine/labelStyleOverride'; + +describe('labelStyleOverride', () => { + beforeEach(() => clearLabelStyleOverride()); + // Always reinstall a no-op wake at teardown so a test that registered + // a counter doesn't leak its closure into the next file's runs. + afterEach(() => registerLabelStyleOverrideWake(() => {})); + + it('returns null target when no override is set', () => { + expect(getLabelStyleOverride().targetCategory).toBeNull(); + }); + + it('stores the most recent override', () => { + setLabelStyleOverride({ + targetCategory: 'cluster', + outlineColor: [1, 0, 0, 1], + outlineEmFrac: 0.05, + }); + const v = getLabelStyleOverride(); + expect(v.targetCategory).toBe('cluster'); + expect(v.outlineColor).toEqual([1, 0, 0, 1]); + expect(v.outlineEmFrac).toBe(0.05); + }); + + it('clearLabelStyleOverride resets targetCategory to null', () => { + setLabelStyleOverride({ + targetCategory: 'void', + outlineColor: [0, 0, 0, 0], + outlineEmFrac: 0, + }); + clearLabelStyleOverride(); + expect(getLabelStyleOverride().targetCategory).toBeNull(); + }); + + it('fires the registered wake callback on set and clear', () => { + let wakes = 0; + registerLabelStyleOverrideWake(() => { + wakes++; + }); + setLabelStyleOverride({ + targetCategory: 'cluster', + outlineColor: [1, 0, 0, 1], + outlineEmFrac: 0.05, + }); + expect(wakes).toBe(1); + clearLabelStyleOverride(); + expect(wakes).toBe(2); + }); +}); diff --git a/tests/services/engine/subsystems/labelDirectorSubsystem.override.test.ts b/tests/services/engine/subsystems/labelDirectorSubsystem.override.test.ts new file mode 100644 index 00000000..5b992e51 --- /dev/null +++ b/tests/services/engine/subsystems/labelDirectorSubsystem.override.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLabelDirectorSubsystem } from '../../../../src/services/engine/subsystems/labelDirectorSubsystem'; +import { + clearLabelStyleOverride, + setLabelStyleOverride, +} from '../../../../src/services/engine/labelStyleOverride'; +import type { LabelProducer } from '../../../../src/@types/engine/subsystems/LabelProducer'; +import type { Label } from '../../../../src/@types/rendering/Label'; +import type { MarkerLine } from '../../../../src/@types/rendering/MarkerLine'; +import type { ReadyFrameContext } from '../../../../src/@types/engine/frame/ReadyFrameContext'; +import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; + +function makeState(requestRender: () => void = () => {}): EngineState { + return { subsystems: { scheduler: { requestRender } } } as unknown as EngineState; +} + +function makeCtx(): ReadyFrameContext { + return { drawCamPos: [0, 0, 0] } as unknown as ReadyFrameContext; +} + +function makeProducer(id: string, labels: Label[], lines: MarkerLine[], awake = false): LabelProducer { + return { id, produceLabels: () => ({ labels, lines, awake }) }; +} + +function makeLabelStub() { + return { setLabels: vi.fn(), render: vi.fn(), glyphCount: () => 0, labelCount: () => 0, destroy: vi.fn() }; +} +function makeLineStub() { + return { setLines: vi.fn(), render: vi.fn(), lineCount: () => 0, destroy: vi.fn() }; +} + +const SAMPLE_LABEL: Label = { + id: 'sample-label', + worldPos: [0, 0, 0], + text: 'x', + font: 'cormorant', + pixelSize: 10, +}; +const SAMPLE_LINE: MarkerLine = { + id: 'sample-line', + fromWorld: [0, 0, 0], + toWorld: [1, 0, 0], + pixelWidth: 1, + color: [1, 1, 1, 1], +}; + +describe('labelDirectorSubsystem — labelStyleOverride wake', () => { + // The override is process-wide module-scoped state; reset before each + // test so prior tests' mutations don't leak in. + beforeEach(() => { + clearLabelStyleOverride(); + }); + + it('re-flushes when the override changes even if merged labels are id+fadeAlpha-stable', () => { + const dir = createLabelDirectorSubsystem(); + const labelStub = makeLabelStub(); + const lineStub = makeLineStub(); + dir.attachRenderers(labelStub as never, lineStub as never); + // A constant producer — same single label + line, frame after frame. + // Without the override-version term in the signature, frames 2+ would + // short-circuit and skip the GPU upload. + dir.registerProducer(makeProducer('p', [SAMPLE_LABEL], [SAMPLE_LINE])); + + dir.runFrame(makeState(), makeCtx()); + dir.runFrame(makeState(), makeCtx()); + // Sanity: the dedupe path is intact for the stable producer. + expect(labelStub.setLabels).toHaveBeenCalledTimes(1); + expect(lineStub.setLines).toHaveBeenCalledTimes(1); + + // Edit the override. The producer's output is unchanged, but the + // director's signature must include the override version so the + // next frame re-flushes (so a DebugPanel slider edit takes effect + // immediately rather than waiting for some other invalidator). + setLabelStyleOverride({ + targetCategory: 'youAreHere', + outlineColor: [0, 0, 0, 1], + outlineEmFrac: 0.1, + }); + + dir.runFrame(makeState(), makeCtx()); + expect(labelStub.setLabels).toHaveBeenCalledTimes(2); + expect(lineStub.setLines).toHaveBeenCalledTimes(2); + }); + + it('re-flushes on clearLabelStyleOverride as well (both setter and clearer bump the version)', () => { + const dir = createLabelDirectorSubsystem(); + const labelStub = makeLabelStub(); + const lineStub = makeLineStub(); + dir.attachRenderers(labelStub as never, lineStub as never); + dir.registerProducer(makeProducer('p', [SAMPLE_LABEL], [SAMPLE_LINE])); + + // Prime with an override active so we start at version > 0. + setLabelStyleOverride({ + targetCategory: 'youAreHere', + outlineColor: [0, 0, 0, 1], + outlineEmFrac: 0.1, + }); + dir.runFrame(makeState(), makeCtx()); + dir.runFrame(makeState(), makeCtx()); + expect(labelStub.setLabels).toHaveBeenCalledTimes(1); + + clearLabelStyleOverride(); + dir.runFrame(makeState(), makeCtx()); + expect(labelStub.setLabels).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/services/engine/subsystems/poiSubsystem.labelEffects.test.ts b/tests/services/engine/subsystems/poiSubsystem.labelEffects.test.ts new file mode 100644 index 00000000..34e7313a --- /dev/null +++ b/tests/services/engine/subsystems/poiSubsystem.labelEffects.test.ts @@ -0,0 +1,85 @@ +/** + * poiSubsystem · labelStyleOverride integration + * + * Exercises the override path through the real producer: when the + * DebugPanel's LabelEffectsSection picks a POI category as the target, + * any POI whose own category matches adopts the override's outline + * fields; non-matching POIs keep their category-default outline. + * + * State stub: the producer only touches state.subsystems.fades.fadeTo + * (one-shot layer fade-in). Context stub mirrors poiSubsystem.test.ts + * — a 60° fovY at 1920×1080. The cluster POI sits at +X with + * physicalRadiusMpc set so the marker pass keeps the anchor gate happy + * (label visibility for clusters is gated on the marker being visible). + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPoiSubsystem } from '../../../../src/services/engine/subsystems/poiSubsystem'; +import { + clearLabelStyleOverride, + setLabelStyleOverride, +} from '../../../../src/services/engine/labelStyleOverride'; +import type { PointOfInterest } from '../../../../src/@types/engine/subsystems/PointOfInterest'; +import type { ReadyFrameContext } from '../../../../src/@types/engine/frame/ReadyFrameContext'; +import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; + +function makeState(): EngineState { + return { + subsystems: { + scheduler: { requestRender: () => {} }, + fades: { fadeTo: () => Promise.resolve() }, + }, + } as unknown as EngineState; +} + +function makeCtx(): ReadyFrameContext { + return { + drawCamPos: [0, 0, 0], + canvasSize: { width: 1920, height: 1080 }, + drawPxPerRad: 1080 / (2 * Math.tan((60 * Math.PI) / 180 / 2)), + } as unknown as ReadyFrameContext; +} + +const VIRGO: PointOfInterest = { + id: 'virgo', + name: 'Virgo', + category: 'cluster', + worldPos: [10, 0, 0], + physicalRadiusMpc: 2, +}; + +describe('poiSubsystem · labelStyleOverride', () => { + beforeEach(() => { + clearLabelStyleOverride(); + }); + + it('applies the override only to labels whose category matches', () => { + setLabelStyleOverride({ + targetCategory: 'cluster', + outlineColor: [1, 1, 0, 1], + outlineEmFrac: 0.06, + }); + const sub = createPoiSubsystem(); + sub.setPois([VIRGO]); + const out = sub.produceLabels(makeState(), makeCtx()); + expect(out.labels).toHaveLength(1); + const label = out.labels[0]!; + expect(label.outlineColor).toEqual([1, 1, 0, 1]); + expect(label.outlineEmFrac).toBe(0.06); + }); + + it('falls back to the category baked-in outline when override targets another category', () => { + setLabelStyleOverride({ + targetCategory: 'void', + outlineColor: [1, 1, 0, 1], + outlineEmFrac: 0.06, + }); + const sub = createPoiSubsystem(); + sub.setPois([VIRGO]); + const out = sub.produceLabels(makeState(), makeCtx()); + expect(out.labels).toHaveLength(1); + const label = out.labels[0]!; + expect(label.outlineColor).toEqual([0, 0, 0, 0.1]); + expect(label.outlineEmFrac).toBe(0.16); + }); +}); diff --git a/tests/services/engine/subsystems/poiSubsystem.test.ts b/tests/services/engine/subsystems/poiSubsystem.test.ts index d3849dd2..a0970efa 100644 --- a/tests/services/engine/subsystems/poiSubsystem.test.ts +++ b/tests/services/engine/subsystems/poiSubsystem.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { createPoiSubsystem } from '../../../../src/services/engine/subsystems/poiSubsystem'; +import { + createPoiSubsystem, + POI_STYLES, +} from '../../../../src/services/engine/subsystems/poiSubsystem'; import type { PointOfInterest } from '../../../../src/@types/engine/subsystems/PointOfInterest'; import type { ReadyFrameContext } from '../../../../src/@types/engine/frame/ReadyFrameContext'; import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; @@ -512,3 +515,18 @@ describe('poiSubsystem · marker/label visibility', () => { expect(markers).toHaveLength(1); }); }); + +describe('POI_STYLES labelColor alpha', () => { + it('every labelColor has alpha=1 so the straight->premultiplied migration is a no-op', () => { + // Migration safety: the label pack loop now multiplies rgb * a on + // write (straight RGBA -> premultiplied at the GPU boundary). If a + // future POI_STYLES edit lowers a labelColor's alpha below 1, the + // new pack-loop premultiplication will silently dim its RGB + // channels relative to the pre-migration behaviour. This test + // fails loudly so the implementer can either re-balance the RGB + // intent or confirm the dimming was deliberate. + for (const [category, style] of Object.entries(POI_STYLES)) { + expect(style.labelColor[3], `${category}.labelColor alpha`).toBe(1); + } + }); +}); diff --git a/tests/services/engine/subsystems/youAreHereSubsystem.labelEffects.test.ts b/tests/services/engine/subsystems/youAreHereSubsystem.labelEffects.test.ts new file mode 100644 index 00000000..a0ff1ebf --- /dev/null +++ b/tests/services/engine/subsystems/youAreHereSubsystem.labelEffects.test.ts @@ -0,0 +1,74 @@ +/** + * youAreHereSubsystem · labelStyleOverride integration + * + * Exercises the override path through the real producer: when the + * DebugPanel's LabelEffectsSection picks 'youAreHere' as the target + * category, the produced label adopts the override's outline fields; + * when it picks another category, the label falls back to the + * producer's baked drop-shadow outline (OUTLINE_COLOR + OUTLINE_EM_FRAC + * in `youAreHereSubsystem.ts`). + * + * State stub: the producer only touches state.subsystems.fades.fadeTo + * (one-shot layer fade-in). A no-op stub suffices. + * + * Context stub: youAreHereAlpha is a pure function of |drawCamPos|, so + * we place the camera at the origin — the alpha is comfortably above 0 + * and the produceLabels body reaches the single labels.push. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { createYouAreHereSubsystem } from '../../../../src/services/engine/subsystems/youAreHereSubsystem'; +import { + clearLabelStyleOverride, + setLabelStyleOverride, +} from '../../../../src/services/engine/labelStyleOverride'; +import type { ReadyFrameContext } from '../../../../src/@types/engine/frame/ReadyFrameContext'; +import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; + +function makeState(): EngineState { + return { + subsystems: { + scheduler: { requestRender: () => {} }, + fades: { fadeTo: () => Promise.resolve() }, + }, + } as unknown as EngineState; +} + +function makeCtx(): ReadyFrameContext { + return { drawCamPos: [0, 0, 0] } as unknown as ReadyFrameContext; +} + +describe('youAreHereSubsystem · labelStyleOverride', () => { + beforeEach(() => { + clearLabelStyleOverride(); + }); + + it('applies the override when targetCategory is youAreHere', () => { + setLabelStyleOverride({ + targetCategory: 'youAreHere', + outlineColor: [1, 0, 0, 1], + outlineEmFrac: 0.08, + }); + const sub = createYouAreHereSubsystem(); + const out = sub.produceLabels(makeState(), makeCtx()); + expect(out.labels).toHaveLength(1); + const label = out.labels[0]!; + expect(label.outlineColor).toEqual([1, 0, 0, 1]); + expect(label.outlineEmFrac).toBe(0.08); + }); + + it('falls back to the baked drop-shadow outline when override targets another category', () => { + setLabelStyleOverride({ + targetCategory: 'cluster', + outlineColor: [1, 0, 0, 1], + outlineEmFrac: 0.08, + }); + const sub = createYouAreHereSubsystem(); + const out = sub.produceLabels(makeState(), makeCtx()); + expect(out.labels).toHaveLength(1); + const label = out.labels[0]!; + // Producer defaults: a soft black drop-shadow outline. + expect(label.outlineColor).toEqual([0, 0, 0, 0.1]); + expect(label.outlineEmFrac).toBe(0.16); + }); +}); diff --git a/tests/services/gpu/renderers/labelRenderer.colorMigration.test.ts b/tests/services/gpu/renderers/labelRenderer.colorMigration.test.ts new file mode 100644 index 00000000..3bd1ef3b --- /dev/null +++ b/tests/services/gpu/renderers/labelRenderer.colorMigration.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { createLabelRenderer } from '../../../../src/services/gpu/renderers/labelRenderer'; +import { parseFontMetrics } from '../../../../src/services/gpu/labels/fontMetrics'; +import type { LoadedFontAtlases } from '../../../../src/@types/rendering/LoadedFontAtlases'; + +const FIXTURE_METRICS = parseFontMetrics({ + pages: ['x.png'], + common: { lineHeight: 50, base: 38, scaleW: 512, scaleH: 512 }, + info: { face: 'X', size: 42 }, + distanceField: { fieldType: 'msdf', distanceRange: 16 }, + chars: [ + { id: 65, x: 0, y: 0, width: 30, height: 40, xoffset: 0, yoffset: 0, xadvance: 25, page: 0, chnl: 15 }, + ], +}); +const FIXTURE_ATLASES: LoadedFontAtlases = { metricsByFont: { cormorant: FIXTURE_METRICS }, bitmaps: [] }; + +describe('LabelRenderer color migration to straight RGBA', () => { + it('premultiplies straight RGBA on write to the storage buffer', () => { + const r = createLabelRenderer( + { device: null as unknown as GPUDevice, context: null as unknown as GPUCanvasContext, + format: 'rgba16float' as GPUTextureFormat, canvas: null as unknown as HTMLCanvasElement }, + FIXTURE_ATLASES, + ); + r.setLabels([{ + id: 'a', worldPos: [0, 0, 0], text: 'A', pixelSize: 0, font: 'cormorant', + color: [1, 0.5, 0.25, 0.5], + }]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + // color slot is bytes 16..31, f32 indices 4..7 + expect(buf[4]).toBeCloseTo(0.5, 5); // 1 * 0.5 + expect(buf[5]).toBeCloseTo(0.25, 5); // 0.5 * 0.5 + expect(buf[6]).toBeCloseTo(0.125, 5); // 0.25 * 0.5 + expect(buf[7]).toBeCloseTo(0.5, 5); // alpha unchanged + }); + + it('defaults to opaque white when color is omitted', () => { + const r = createLabelRenderer( + { device: null as unknown as GPUDevice, context: null as unknown as GPUCanvasContext, + format: 'rgba16float' as GPUTextureFormat, canvas: null as unknown as HTMLCanvasElement }, + FIXTURE_ATLASES, + ); + r.setLabels([{ id: 'a', worldPos: [0, 0, 0], text: 'A', pixelSize: 0, font: 'cormorant' }]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + expect(buf[4]).toBe(1); + expect(buf[5]).toBe(1); + expect(buf[6]).toBe(1); + expect(buf[7]).toBe(1); + }); +}); diff --git a/tests/services/gpu/renderers/labelRenderer.effects.test.ts b/tests/services/gpu/renderers/labelRenderer.effects.test.ts new file mode 100644 index 00000000..a30db5a2 --- /dev/null +++ b/tests/services/gpu/renderers/labelRenderer.effects.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { createLabelRenderer } from '../../../../src/services/gpu/renderers/labelRenderer'; +import { parseFontMetrics } from '../../../../src/services/gpu/labels/fontMetrics'; +import type { LoadedFontAtlases } from '../../../../src/@types/rendering/LoadedFontAtlases'; + +const FIXTURE_METRICS = parseFontMetrics({ + pages: ['x.png'], + common: { lineHeight: 50, base: 38, scaleW: 512, scaleH: 512 }, + info: { face: 'X', size: 42 }, + distanceField: { fieldType: 'msdf', distanceRange: 16 }, + chars: [ + { id: 65, x: 0, y: 0, width: 30, height: 40, xoffset: 0, yoffset: 0, xadvance: 25, page: 0, chnl: 15 }, + ], +}); +const FIXTURE_ATLASES: LoadedFontAtlases = { metricsByFont: { cormorant: FIXTURE_METRICS }, bitmaps: [] }; +const newRenderer = () => + createLabelRenderer( + { + device: null as unknown as GPUDevice, + context: null as unknown as GPUCanvasContext, + format: 'rgba16float' as GPUTextureFormat, + canvas: null as unknown as HTMLCanvasElement, + }, + FIXTURE_ATLASES, + ); + +describe('LabelRenderer effect-field pack layout', () => { + it('per-label storage record is 16 f32 slots (64 bytes)', () => { + const r = newRenderer(); + r.setLabels([ + { id: 'a', worldPos: [0, 0, 0], text: 'A', pixelSize: 0, font: 'cormorant' }, + { id: 'b', worldPos: [7, 8, 9], text: 'A', pixelSize: 0, font: 'cormorant' }, + ]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + // Second label's worldPos starts at slot 16 (= 64-byte stride / 4). + expect(buf[16]).toBe(7); + expect(buf[17]).toBe(8); + expect(buf[18]).toBe(9); + }); + + it('writes outlineColor (premultiplied) at slots 12..15', () => { + const r = newRenderer(); + r.setLabels([ + { + id: 'a', + worldPos: [0, 0, 0], + text: 'A', + pixelSize: 0, + font: 'cormorant', + outlineColor: [1, 0, 0, 0.5], + }, + ]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + expect(buf[12]).toBeCloseTo(0.5, 5); // 1 * 0.5 + expect(buf[13]).toBe(0); + expect(buf[14]).toBe(0); + expect(buf[15]).toBeCloseTo(0.5, 5); + }); + + it('writes outlineEmFrac at sizing.x (slot 8)', () => { + const r = newRenderer(); + r.setLabels([ + { + id: 'a', + worldPos: [0, 0, 0], + text: 'A', + pixelSize: 0, + font: 'cormorant', + outlineEmFrac: 0.07, + }, + ]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + expect(buf[8]).toBeCloseTo(0.07, 5); + }); + + it('defaults outline fields to zero when omitted', () => { + const r = newRenderer(); + r.setLabels([{ id: 'a', worldPos: [0, 0, 0], text: 'A', pixelSize: 0, font: 'cormorant' }]); + const buf = (r as unknown as { __debugLabelBuf(): Float32Array }).__debugLabelBuf(); + expect(buf[8]).toBe(0); // outlineEmFrac + expect(buf[12]).toBe(0); + expect(buf[13]).toBe(0); + expect(buf[14]).toBe(0); + expect(buf[15]).toBe(0); + }); +}); diff --git a/tests/tools/buildFontAtlas.distanceRange.test.ts b/tests/tools/buildFontAtlas.distanceRange.test.ts new file mode 100644 index 00000000..453d0478 --- /dev/null +++ b/tests/tools/buildFontAtlas.distanceRange.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { DISTANCE_RANGE_PX } from '../../src/data/fonts'; + +describe('font atlas distance range', () => { + it('bakes at distanceRange 16 so the SDF carries headroom for outline + glow', () => { + // Headroom rationale: the up-to-12-px glow extent at maxPixelSize plus + // ~2 px of outline must stay inside the SDF's encoded range. 4 (the + // msdf-bmfont-xml default) clamped the falloff tail; 16 leaves ~25% + // margin past the worst-case effect extent. + expect(DISTANCE_RANGE_PX).toBe(16); + }); +}); diff --git a/tools/fonts/buildFontAtlas.ts b/tools/fonts/buildFontAtlas.ts index 9beb0ee6..971d8b8c 100644 --- a/tools/fonts/buildFontAtlas.ts +++ b/tools/fonts/buildFontAtlas.ts @@ -61,7 +61,14 @@ const OUTPUT_DIR = 'public/fonts'; const SHARED_OPTIONS = { outputType: 'json', textureSize: [ATLAS_PX, ATLAS_PX], - texturePadding: 2, + // Inter-glyph spacing in the atlas, in pixels. Must be large enough + // that a fragment sampling a UV offset outward from a glyph for the + // outline+glow falloff never lands in a NEIGHBOURING glyph's pixels. + // Worst case at runtime is `glowEmFrac_max * ATLAS_FONT_SIZE` + // atlas pixels (LabelEffectsSection caps glowEmFrac at 0.5; with + // ATLAS_FONT_SIZE = 42 the worst-case extent is 21 px). 22 leaves + // a 1-px safety margin without inflating glyph cells excessively. + texturePadding: 12, distanceRange: DISTANCE_RANGE_PX, fieldType: 'msdf', fontSize: ATLAS_FONT_SIZE,