pcb-scene3d-viewer 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. package/src/styles/scene3d.css +295 -0
@@ -0,0 +1,671 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ const strokeFontScale = 1 / 21
5
+ const fontOffset = -8
6
+ const firstPrintableCodePoint = 32
7
+ const fallbackGlyphIndex = '?'.charCodeAt(0) - firstPrintableCodePoint
8
+ const tabWidth = 4
9
+ const overbarTrimRatio = 0.1
10
+ const overbarHeightRatio = 1.23
11
+ const superSubSizeMultiplier = 0.8
12
+ const superscriptHeightOffset = 0.35
13
+ const subscriptHeightOffset = 0.15
14
+ const textStyleSubscript = 1
15
+ const textStyleSuperscript = 2
16
+
17
+ // Printable ASCII subset of KiCad's newstroke_font.cpp glyph table.
18
+ const asciiNewstrokeFont = [
19
+ 'JZ',
20
+ 'MWRYSZR[QZRYR[ RRSQGRFSGRSRF',
21
+ 'JZNFNJ RVFVJ',
22
+ 'H]LM[M RRDL_ RYVJV RS_YD',
23
+ 'H\\LZO[T[VZWYXWXUWSVRTQPPNOMNLLLJMHNGPFUFXG RRCR^',
24
+ 'F^J[ZF RMFOGPIOKMLKKJIKGMF RYZZXYVWUUVTXUZW[YZ',
25
+ 'E_[[Z[XZUWPQNNMKMINGPFQFSGTITJSLRMLQKRJTJWKYLZN[Q[SZTYWUXRXP',
26
+ 'MWSFQJ',
27
+ 'KYVcUbS_R]QZPUPQQLRISGUDVC',
28
+ 'KYNcObQ_R]SZTUTQSLRIQGODNC',
29
+ 'JZRFRK RMIRKWI ROORKUO',
30
+ 'E_JSZS RR[RK',
31
+ 'MWSZS[R]Q^',
32
+ 'E_JSZS',
33
+ 'MWRYSZR[QZRYR[',
34
+ 'G][EI`',
35
+ 'H\\QFSFUGVHWJXNXSWWVYUZS[Q[OZNYMWLSLNMJNHOGQF',
36
+ 'H\\X[L[ RR[RFPINKLL',
37
+ 'H\\LHMGOFTFVGWHXJXLWOK[X[',
38
+ 'H\\KFXFQNTNVOWPXRXWWYVZT[N[LZKY',
39
+ 'H\\VMV[ RQELTYT',
40
+ 'H\\WFMFLPMOONTNVOWPXRXWWYVZT[O[MZLY',
41
+ 'H\\VFRFPGOHMKLOLWMYNZP[T[VZWYXWXRWPVOTNPNNOMPLR',
42
+ 'H\\KFYFP[',
43
+ 'H\\PONNMMLKLJMHNGPFTFVGWHXJXKWMVNTOPONPMQLSLWMYNZP[T[VZWYXWXSWQVPTO',
44
+ 'H\\N[R[TZUYWVXRXJWHVGTFPFNGMHLJLOMQNRPSTSVRWQXO',
45
+ 'MWRYSZR[QZRYR[ RRNSORPQORNRP',
46
+ 'MWSZS[R]Q^ RRNSORPQORNRP',
47
+ 'E_ZMJSZY',
48
+ 'E_JPZP RZVJV',
49
+ 'E_JMZSJY',
50
+ 'I[QYRZQ[PZQYQ[ RMGOFTFVGWIWKVMUNSORPQRQS',
51
+ 'D_VQUPSOQOOPNQMSMUNWOXQYSYUXVW RVOVWWXXXZW[U[PYMVKRJNKKMIPHTIXK[N]R^V]Y[',
52
+ 'I[MUWU RK[RFY[',
53
+ 'G\\SPVQWRXTXWWYVZT[L[LFSFUGVHWJWLVNUOSPLP',
54
+ 'F[WYVZS[Q[NZLXKVJRJOKKLINGQFSFVGWH',
55
+ 'G\\L[LFQFTGVIWKXOXRWVVXTZQ[L[',
56
+ 'H[MPTP RW[M[MFWF',
57
+ 'HZTPMP RM[MFWF',
58
+ 'F[VGTFQFNGLIKKJOJRKVLXNZQ[S[VZWYWRSR',
59
+ 'G]L[LF RLPXP RX[XF',
60
+ 'MWR[RF',
61
+ 'JZUFUUTXRZO[M[',
62
+ 'G\\L[LF RX[OO RXFLR',
63
+ 'HYW[M[MF',
64
+ 'F^K[KFRUYFY[',
65
+ 'G]L[LFX[XF',
66
+ 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF',
67
+ 'G\\L[LFTFVGWHXJXMWOVPTQLQ',
68
+ 'G]Z]X\\VZSWQVOV RP[NZLXKTKMLINGPFTFVGXIYMYTXXVZT[P[',
69
+ 'G\\X[QQ RL[LFTFVGWHXJXMWOVPTQLQ',
70
+ 'H\\LZO[T[VZWYXWXUWSVRTQPPNOMNLLLJMHNGPFUFXG',
71
+ 'JZLFXF RR[RF',
72
+ 'G]LFLWMYNZP[T[VZWYXWXF',
73
+ 'I[KFR[YF',
74
+ 'F^IFN[RLV[[F',
75
+ 'H\\KFY[ RYFK[',
76
+ 'I[RQR[ RKFRQYF',
77
+ 'H\\KFYFK[Y[',
78
+ 'KYVbQbQDVD',
79
+ 'KYID[_',
80
+ 'KYNbSbSDND',
81
+ 'LXNHREVH',
82
+ 'JZJ]Z]',
83
+ 'NVPESH',
84
+ 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR',
85
+ 'H[M[MF RMNOMSMUNVOWQWWVYUZS[O[MZ',
86
+ 'HZVZT[P[NZMYLWLQMONNPMTMVN',
87
+ 'I\\W[WF RWZU[Q[OZNYMWMQNOONQMUMWN',
88
+ 'I[VZT[P[NZMXMPNNPMTMVNWPWRMT',
89
+ 'MYOMWM RR[RISGUFWF',
90
+ 'I\\WMW^V`UaSbPbNa RWZU[Q[OZNYMWMQNOONQMUMWN',
91
+ 'H[M[MF RV[VPUNSMPMNNMO',
92
+ 'MWR[RM RRFQGRHSGRFRH',
93
+ 'MWRMR_QaObNb RRFQGRHSGRFRH',
94
+ 'IZN[NF RPSV[ RVMNU',
95
+ 'MXU[SZRXRF',
96
+ 'D`I[IM RIOJNLMOMQNRPR[ RRPSNUMXMZN[P[[',
97
+ 'I\\NMN[ RNOONQMTMVNWPW[',
98
+ 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[',
99
+ 'H[MMMb RMNOMSMUNVOWQWWVYUZS[O[MZ',
100
+ 'I\\WMWb RWZU[Q[OZNYMWMQNOONQMUMWN',
101
+ 'KXP[PM RPQQORNTMVM',
102
+ 'J[NZP[T[VZWXWWVUTTQTOSNQNPONQMTMVN',
103
+ 'MYOMWM RRFRXSZU[W[',
104
+ 'H[VMV[ RMMMXNZP[S[UZVY',
105
+ 'JZMMR[WM',
106
+ 'G]JMN[RQV[ZM',
107
+ 'IZL[WM RLMW[',
108
+ 'JZMMR[ RWMR[P`OaMb',
109
+ 'IZLMWML[W[',
110
+ 'KYVcUcSbR`RVQTOSQRRPRFSDUCVC',
111
+ 'H\\RbRD',
112
+ 'KYNcOcQbR`RVSTUSSRRPRFQDOCNC',
113
+ 'KZMSNRPQTSVRWQ'
114
+ ]
115
+ // Latin-1 Supplement subset from KiCad's generated newstroke_font.cpp glyph table.
116
+ const latin1NewstrokeFont = new Map([
117
+ [0x00a0, 'JZ'],
118
+ [0x00a1, 'MWROQNRMSNRORM RRUSaRbQaRURb'],
119
+ [0x00a2, 'HZVZT[P[NZMYLWLQMONNPMTMVN RRJR^'],
120
+ [0x00a3, 'H[LMTM RL[W[ RO[OIPGRFUFWG'],
121
+ [0x00a4, 'H]LYOV RLLOO RVVYY RVOYL RVVTWQWOVNTNQOOQNTNVOWQWTVV'],
122
+ [0x00a5, 'F^JTZT RJMZM RRQR[ RKFRQYF'],
123
+ [0x00a6, 'MWRbRW RRFRQ'],
124
+ [0x00a7, 'I[N]P^S^U]V[UYOSNQNPONQM RVGTFQFOGNIOKUQVSVTUVSW'],
125
+ [0x00a8, 'LXNFOGNHMGNFNH RVFWGVHUGVFVH'],
126
+ [
127
+ 0x00a9,
128
+ '@dVKTJPJNKLMKOKSLUNWPXTXVW RRCMDHGELDQEVH[M^R_W^\\[_V`Q_L\\GWDRC'
129
+ ],
130
+ [0x00aa, 'KZOEQDSDUEVGVN RVMTNQNOMNKOIQHVH'],
131
+ [0x00ab, 'H\\RMLSRY RXWTSXO'],
132
+ [0x00ac, 'E_JQZQZV'],
133
+ [0x00ad, 'RR'],
134
+ [
135
+ 0x00ae,
136
+ '@dWXRR RNXNJTJVKWMWOVQTRNR RRCMDHGELDQEVH[M^R_W^\\[_V`Q_L\\GWDRC'
137
+ ],
138
+ [0x00af, 'LXMGWG'],
139
+ [0x00b0, 'JZRFPGOIPKRLTKUITGRF'],
140
+ [0x00b1, 'E_JOZO RRWRG RZ[J['],
141
+ [0x00b2, 'JZNAP@S@UAVCVEUGNNVN'],
142
+ [0x00b3, 'JZN@V@RESEUFVHVKUMSNPNNM'],
143
+ [0x00b4, 'NVTEQH'],
144
+ [0x00b5, 'H^MMMb RWXXZZ[ RMXNZP[T[VZWXWM'],
145
+ [0x00b6, 'F]VMV[ ROMOXNZL[ RZMMMKNJP'],
146
+ [0x00b7, 'JZRRQSRTSSRRRT'],
147
+ [0x00b8, 'MWR\\T]U_TaRbOb'],
148
+ [0x00b9, 'JZVNNN RNCPBR@RN'],
149
+ [0x00ba, 'KYQNOMNKNGOEQDSDUEVGVKUMSNQN'],
150
+ [0x00bb, 'H\\RMXSRY RLWPSLO'],
151
+ [0x00bc, 'G]KQYQ RVNNN RNCPBR@RN RUYUa RQSN]W]'],
152
+ [0x00bd, 'G]KQYQ RVNNN RNCPBR@RN RNTPSSSUTVVVXUZNaVa'],
153
+ [0x00be, 'G]KQYQ RN@V@RESEUFVHVKUMSNPNNM RUYUa RQSN]W]'],
154
+ [0x00bf, 'I[SORNSMTNSOSM RWaUbPbNaM_M]N[OZQYRXSVSU'],
155
+ [0x00c0, 'I[MUWU RK[RFY[ RP>SA'],
156
+ [0x00c1, 'I[MUWU RK[RFY[ RT>QA'],
157
+ [0x00c2, 'I[MUWU RK[RFY[ RNAR>VA'],
158
+ [0x00c3, 'I[MUWU RK[RFY[ RMAN@P?TAV@W?'],
159
+ [0x00c4, 'I[MUWU RK[RFY[ RN?O@NAM@N?NA RV?W@VAU@V?VA'],
160
+ [0x00c5, 'I[MUWU RK[RFY[ RRFPEOCPAR@TAUCTERF'],
161
+ [0x00c6, 'F`JURU RRPYP RH[OF\\F RRFR[\\['],
162
+ [0x00c7, 'F[WYVZS[Q[NZLXKVJRJOKKLINGQFSFVGWH RR\\T]U_TaRbOb'],
163
+ [0x00c8, 'H[MPTP RW[M[MFWF RP>SA'],
164
+ [0x00c9, 'H[MPTP RW[M[MFWF RT>QA'],
165
+ [0x00ca, 'H[MPTP RW[M[MFWF RNAR>VA'],
166
+ [0x00cb, 'H[MPTP RW[M[MFWF RN?O@NAM@N?NA RV?W@VAU@V?VA'],
167
+ [0x00cc, 'MWR[RF RP>SA'],
168
+ [0x00cd, 'MWR[RF RT>QA'],
169
+ [0x00ce, 'MWR[RF RNAR>VA'],
170
+ [0x00cf, 'MWR[RF RN?O@NAM@N?NA RV?W@VAU@V?VA'],
171
+ [0x00d0, 'G\\L[LFQFTGVIWKXOXRWVVXTZQ[L[ RIPQP'],
172
+ [0x00d1, 'G]L[LFX[XF RMAN@P?TAV@W?'],
173
+ [0x00d2, 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF RP>SA'],
174
+ [0x00d3, 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF RT>QA'],
175
+ [0x00d4, 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF RNAR>VA'],
176
+ [0x00d5, 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF RMAN@P?TAV@W?'],
177
+ [
178
+ 0x00d6,
179
+ 'G]PFTFVGXIYMYTXXVZT[P[NZLXKTKMLINGPF RN?O@NAM@N?NA RV?W@VAU@V?VA'
180
+ ],
181
+ [0x00d7, 'E_LMXY RXMLY'],
182
+ [0x00d8, 'G]ZFJ[ RP[NZLXKTKMLINGPFTFVGXIYMYTXXVZT[P['],
183
+ [0x00d9, 'G]LFLWMYNZP[T[VZWYXWXF RP>SA'],
184
+ [0x00da, 'G]LFLWMYNZP[T[VZWYXWXF RT>QA'],
185
+ [0x00db, 'G]LFLWMYNZP[T[VZWYXWXF RNAR>VA'],
186
+ [0x00dc, 'G]LFLWMYNZP[T[VZWYXWXF RN?O@NAM@N?NA RV?W@VAU@V?VA'],
187
+ [0x00dd, 'I[RQR[ RKFRQYF RT>QA'],
188
+ [0x00de, 'G\\LFL[ RLKTKVLWMXOXRWTVUTVLV'],
189
+ [0x00df, 'F]K[KJLHMGOFRFTGUHVJVMSMQNPPPQQSSTVTXUYWYXXZV[R[PZ'],
190
+ [0x00e0, 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RPESH'],
191
+ [0x00e1, 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RTEQH'],
192
+ [0x00e2, 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RNHREVH'],
193
+ [0x00e3, 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RMHNGPFTHVGWF'],
194
+ [
195
+ 0x00e4,
196
+ 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RNFOGNHMGNFNH RVFWGVHUGVFVH'
197
+ ],
198
+ [0x00e5, 'I\\W[WPVNTMPMNN RWZU[P[NZMXMVNTPSUSWR RRHPGOEPCRBTCUETGRH'],
199
+ [0x00e6, 'D`INKMOMQNRP R[ZY[U[SZRXRPSNUMYM[N\\P\\RRSKSITHVHXIZK[O[QZRX'],
200
+ [0x00e7, 'HZVZT[P[NZMYLWLQMONNPMTMVN RR\\T]U_TaRbOb'],
201
+ [0x00e8, 'I[VZT[P[NZMXMPNNPMTMVNWPWRMT RPESH'],
202
+ [0x00e9, 'I[VZT[P[NZMXMPNNPMTMVNWPWRMT RTEQH'],
203
+ [0x00ea, 'I[VZT[P[NZMXMPNNPMTMVNWPWRMT RNHREVH'],
204
+ [0x00eb, 'I[VZT[P[NZMXMPNNPMTMVNWPWRMT RNFOGNHMGNFNH RVFWGVHUGVFVH'],
205
+ [0x00ec, 'MWR[RM RPESH'],
206
+ [0x00ed, 'MWR[RM RTEQH'],
207
+ [0x00ee, 'LXNHREVH RR[RM'],
208
+ [0x00ef, 'LXNFOGNHMGNFNH RVFWGVHUGVFVH RR[RM'],
209
+ [0x00f0, 'I\\SCQI RWNUMQMONNOMQMXNZP[T[VZWXWLVITGRFNE'],
210
+ [0x00f1, 'I\\NMN[ RNOONQMTMVNWPW[ RMHNGPFTHVGWF'],
211
+ [0x00f2, 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[ RPESH'],
212
+ [0x00f3, 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[ RTEQH'],
213
+ [0x00f4, 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[ RNHREVH'],
214
+ [0x00f5, 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[ RMHNGPFTHVGWF'],
215
+ [
216
+ 0x00f6,
217
+ 'H[P[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P[ RNFOGNHMGNFNH RVFWGVHUGVFVH'
218
+ ],
219
+ [0x00f7, 'E_ZSJS RRXSYRZQYRXRZ RRLSMRNQMRLRN'],
220
+ [0x00f8, 'H[XMK[ RP[NZMYLWLQMONNPMSMUNVOWQWWVYUZS[P['],
221
+ [0x00f9, 'H[VMV[ RMMMXNZP[S[UZVY RPESH'],
222
+ [0x00fa, 'H[VMV[ RMMMXNZP[S[UZVY RTEQH'],
223
+ [0x00fb, 'H[VMV[ RMMMXNZP[S[UZVY RNHREVH'],
224
+ [0x00fc, 'H[VMV[ RMMMXNZP[S[UZVY RNFOGNHMGNFNH RVFWGVHUGVFVH'],
225
+ [0x00fd, 'JZMMR[ RWMR[P`OaMb RTEQH'],
226
+ [0x00fe, 'H[MFMb RMNOMSMUNVOWQWWVYUZS[O[MZ'],
227
+ [0x00ff, 'JZMMR[ RWMR[P`OaMb RNFOGNHMGNFNH RVFWGVHUGVFVH']
228
+ ])
229
+ const glyphs = asciiNewstrokeFont.map(parseGlyph)
230
+ const latin1Glyphs = new Map(
231
+ [...latin1NewstrokeFont].map(([codePoint, glyph]) => [
232
+ codePoint,
233
+ parseGlyph(glyph)
234
+ ])
235
+ )
236
+ const spaceWidth = glyphs[0].bounds.maxX
237
+ const lineLayoutCacheLimit = 2048
238
+ const lineLayoutCache = new Map()
239
+
240
+ /**
241
+ * KiCad NewStroke glyph renderer for PCB text.
242
+ */
243
+ export class PcbScene3dStrokeFont {
244
+ /**
245
+ * Measures one text line using KiCad's full stroke glyph cursor advance.
246
+ * @param {string} value
247
+ * @param {number} sizeX
248
+ * @returns {number}
249
+ */
250
+ static measureLine(value, sizeX) {
251
+ return lineAdvance(value, sizeX)
252
+ }
253
+
254
+ /**
255
+ * Converts one text line into stroke point lists and its advance width.
256
+ * @param {string} value
257
+ * @param {{ x: number, y: number, sizeX: number, sizeY: number }} attrs
258
+ * @returns {{ width: number, strokes: { x: number, y: number }[][] }}
259
+ */
260
+ static layoutLine(value, attrs) {
261
+ return layoutLine(value, attrs)
262
+ }
263
+
264
+ /**
265
+ * Converts one text line into KiCad-scaled stroke point lists.
266
+ * @param {string} value
267
+ * @param {{ x: number, y: number, sizeX: number, sizeY: number }} attrs
268
+ * @returns {{ x: number, y: number }[][]}
269
+ */
270
+ static strokeLine(value, attrs) {
271
+ return layoutLine(value, attrs).strokes
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Measures one KiCad markup-aware text line.
277
+ * @param {string} value Text line.
278
+ * @param {number} sizeX Glyph width.
279
+ * @returns {number}
280
+ */
281
+ function lineAdvance(value, sizeX) {
282
+ return cachedLineLayout(value, sizeX, sizeX).width
283
+ }
284
+
285
+ /**
286
+ * Lays out one line at the requested origin.
287
+ * @param {string} value Text line.
288
+ * @param {{ x: number, y: number, sizeX: number, sizeY: number }} attrs Text attributes.
289
+ * @returns {{ width: number, strokes: { x: number, y: number }[][] }}
290
+ */
291
+ function layoutLine(value, attrs) {
292
+ const state = textRunState(attrs)
293
+ const layout = cachedLineLayout(value, state.sizeX, state.sizeY)
294
+
295
+ return {
296
+ width: layout.width,
297
+ strokes: offsetStrokes(layout.strokes, state.x, state.y)
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Resolves a reusable zero-origin line layout.
303
+ * @param {string} value Text line.
304
+ * @param {number} sizeX Glyph width.
305
+ * @param {number} sizeY Glyph height.
306
+ * @returns {{ width: number, strokes: { x: number, y: number }[][] }}
307
+ */
308
+ function cachedLineLayout(value, sizeX, sizeY) {
309
+ const normalizedValue = String(value || '')
310
+ const normalizedSizeX = Number(sizeX || 1)
311
+ const normalizedSizeY = Number(sizeY || normalizedSizeX || 1)
312
+ const cacheKey = `${normalizedValue}\u0000${normalizedSizeX}\u0000${normalizedSizeY}`
313
+ const cached = lineLayoutCache.get(cacheKey)
314
+
315
+ if (cached) {
316
+ lineLayoutCache.delete(cacheKey)
317
+ lineLayoutCache.set(cacheKey, cached)
318
+ return cached
319
+ }
320
+
321
+ const strokes = []
322
+ const width = Math.max(
323
+ strokeMarkupNodes(
324
+ parseMarkup(normalizedValue),
325
+ { x: 0, y: 0, sizeX: normalizedSizeX, sizeY: normalizedSizeY },
326
+ 0,
327
+ strokes
328
+ ),
329
+ 0
330
+ )
331
+ const layout = { width, strokes }
332
+ lineLayoutCache.set(cacheKey, layout)
333
+
334
+ if (lineLayoutCache.size > lineLayoutCacheLimit) {
335
+ lineLayoutCache.delete(lineLayoutCache.keys().next().value)
336
+ }
337
+
338
+ return layout
339
+ }
340
+
341
+ /**
342
+ * Copies cached zero-origin strokes to a requested line origin.
343
+ * @param {{ x: number, y: number }[][]} strokes Cached strokes.
344
+ * @param {number} offsetX X offset.
345
+ * @param {number} offsetY Y offset.
346
+ * @returns {{ x: number, y: number }[][]}
347
+ */
348
+ function offsetStrokes(strokes, offsetX, offsetY) {
349
+ return strokes.map((stroke) =>
350
+ stroke.map((point) => ({
351
+ x: point.x + offsetX,
352
+ y: point.y + offsetY
353
+ }))
354
+ )
355
+ }
356
+
357
+ /**
358
+ * Resolves a printable glyph, falling back like KiCad's stroke font.
359
+ * @param {string} char Character.
360
+ * @returns {object}
361
+ */
362
+ function glyphForCharacter(char) {
363
+ const codePoint = char.codePointAt(0)
364
+ const index = codePoint - firstPrintableCodePoint
365
+
366
+ if (index >= 0 && index < glyphs.length && glyphs[index]) {
367
+ return glyphs[index]
368
+ }
369
+
370
+ return latin1Glyphs.get(codePoint) || glyphs[fallbackGlyphIndex]
371
+ }
372
+
373
+ /**
374
+ * Parses KiCad text markup commands.
375
+ * @param {string} value Text value.
376
+ * @returns {object[]}
377
+ */
378
+ function parseMarkup(value) {
379
+ return parseMarkupUntil(String(value || ''), 0, '').nodes
380
+ }
381
+
382
+ /**
383
+ * Parses markup nodes until an optional terminator.
384
+ * @param {string} value Text value.
385
+ * @param {number} start Start index.
386
+ * @param {string} terminator Terminator character.
387
+ * @returns {{ nodes: object[], index: number, closed: boolean }}
388
+ */
389
+ function parseMarkupUntil(value, start, terminator) {
390
+ const nodes = []
391
+ let text = ''
392
+ let index = start
393
+
394
+ while (index < value.length) {
395
+ const char = value[index]
396
+
397
+ if (terminator && char === terminator) {
398
+ pushTextNode(nodes, text)
399
+ return { nodes, index, closed: true }
400
+ }
401
+
402
+ if (isMarkupCommand(value, index)) {
403
+ const parsed = parseMarkupUntil(value, index + 2, '}')
404
+
405
+ if (parsed.closed) {
406
+ pushTextNode(nodes, text)
407
+ text = ''
408
+ nodes.push({
409
+ type: markupCommandType(char),
410
+ children: parsed.nodes
411
+ })
412
+ index = parsed.index + 1
413
+ continue
414
+ }
415
+ }
416
+
417
+ text += char
418
+ index += 1
419
+ }
420
+
421
+ pushTextNode(nodes, text)
422
+ return { nodes, index, closed: !terminator }
423
+ }
424
+
425
+ /**
426
+ * Appends a text node when the text is non-empty.
427
+ * @param {object[]} nodes Node list.
428
+ * @param {string} text Text content.
429
+ */
430
+ function pushTextNode(nodes, text) {
431
+ if (text) nodes.push({ type: 'text', value: text })
432
+ }
433
+
434
+ /**
435
+ * Checks for a KiCad markup command prefix.
436
+ * @param {string} value Text value.
437
+ * @param {number} index Candidate index.
438
+ * @returns {boolean}
439
+ */
440
+ function isMarkupCommand(value, index) {
441
+ return ['~', '_', '^'].includes(value[index]) && value[index + 1] === '{'
442
+ }
443
+
444
+ /**
445
+ * Maps KiCad markup command characters to node types.
446
+ * @param {string} char Command character.
447
+ * @returns {string}
448
+ */
449
+ function markupCommandType(char) {
450
+ if (char === '~') return 'overbar'
451
+ if (char === '_') return 'subscript'
452
+ return 'superscript'
453
+ }
454
+
455
+ /**
456
+ * Creates a text-run state from stroke-font attributes.
457
+ * @param {{ x: number, y: number, sizeX: number, sizeY: number }} attrs Text attributes.
458
+ * @returns {{ x: number, y: number, sizeX: number, sizeY: number }}
459
+ */
460
+ function textRunState(attrs) {
461
+ return {
462
+ x: Number(attrs.x || 0),
463
+ y: Number(attrs.y || 0),
464
+ sizeX: Number(attrs.sizeX || 1),
465
+ sizeY: Number(attrs.sizeY || attrs.sizeX || 1)
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Converts markup nodes into stroke paths and returns the next cursor x.
471
+ * @param {object[]} nodes Markup nodes.
472
+ * @param {object} state Text run state.
473
+ * @param {number} style Text style flags.
474
+ * @param {Array[] | undefined} strokes Stroke output.
475
+ * @returns {number}
476
+ */
477
+ function strokeMarkupNodes(nodes, state, style, strokes) {
478
+ let cursorX = state.x
479
+
480
+ for (const node of nodes) {
481
+ const nextState = { ...state, x: cursorX }
482
+ cursorX = strokeMarkupNode(node, nextState, style, strokes)
483
+ }
484
+
485
+ return cursorX
486
+ }
487
+
488
+ /**
489
+ * Converts one markup node into stroke paths.
490
+ * @param {object} node Markup node.
491
+ * @param {object} state Text run state.
492
+ * @param {number} style Text style flags.
493
+ * @param {Array[] | undefined} strokes Stroke output.
494
+ * @returns {number}
495
+ */
496
+ function strokeMarkupNode(node, state, style, strokes) {
497
+ if (node.type === 'text') {
498
+ return strokeTextRun(node.value, state, style, strokes)
499
+ }
500
+
501
+ let nodeStyle = style
502
+ if (node.type === 'subscript') nodeStyle |= textStyleSubscript
503
+ if (node.type === 'superscript') nodeStyle |= textStyleSuperscript
504
+
505
+ const startX = state.x
506
+ const endX = strokeMarkupNodes(
507
+ node.children || [],
508
+ state,
509
+ nodeStyle,
510
+ strokes
511
+ )
512
+
513
+ if (node.type === 'overbar' && strokes) {
514
+ strokes.push(overbarStroke(startX, endX, state))
515
+ }
516
+
517
+ return endX
518
+ }
519
+
520
+ /**
521
+ * Converts one plain-text run into stroke paths.
522
+ * @param {string} value Text run.
523
+ * @param {object} state Text run state.
524
+ * @param {number} style Text style flags.
525
+ * @param {Array[] | undefined} strokes Stroke output.
526
+ * @returns {number}
527
+ */
528
+ function strokeTextRun(value, state, style, strokes) {
529
+ const metrics = styledRunMetrics(state, style)
530
+ let cursorX = state.x
531
+ let charCount = 0
532
+
533
+ for (const char of String(value || '')) {
534
+ if (char === '\t') {
535
+ charCount = Math.floor(charCount / tabWidth + 1) * tabWidth - 1
536
+ let newCursor =
537
+ state.x + metrics.sizeX * charCount + metrics.sizeX * spaceWidth
538
+
539
+ while (newCursor <= cursorX) {
540
+ charCount += tabWidth
541
+ newCursor += metrics.sizeX * tabWidth
542
+ }
543
+
544
+ cursorX = newCursor
545
+ } else if (char === ' ') {
546
+ cursorX += metrics.sizeX * spaceWidth
547
+ } else {
548
+ const glyph = glyphForCharacter(char)
549
+ if (strokes) {
550
+ glyph.strokes.forEach((stroke) => {
551
+ strokes.push(
552
+ stroke.map((point) => ({
553
+ x: cursorX + point.x * metrics.sizeX,
554
+ y: metrics.y + point.y * metrics.sizeY
555
+ }))
556
+ )
557
+ })
558
+ }
559
+ cursorX += glyph.bounds.maxX * metrics.sizeX
560
+ }
561
+
562
+ charCount += 1
563
+ }
564
+
565
+ return cursorX
566
+ }
567
+
568
+ /**
569
+ * Resolves glyph size and baseline for KiCad subscript/superscript flags.
570
+ * @param {object} state Text run state.
571
+ * @param {number} style Text style flags.
572
+ * @returns {{ sizeX: number, sizeY: number, y: number }}
573
+ */
574
+ function styledRunMetrics(state, style) {
575
+ let sizeX = state.sizeX
576
+ let sizeY = state.sizeY
577
+ let y = state.y
578
+
579
+ if (style & (textStyleSubscript | textStyleSuperscript)) {
580
+ sizeX *= superSubSizeMultiplier
581
+ sizeY *= superSubSizeMultiplier
582
+
583
+ if (style & textStyleSubscript) {
584
+ y += sizeY * subscriptHeightOffset
585
+ } else {
586
+ y -= sizeY * superscriptHeightOffset
587
+ }
588
+ }
589
+
590
+ return { sizeX, sizeY, y }
591
+ }
592
+
593
+ /**
594
+ * Builds KiCad's overbar stroke for a marked text range.
595
+ * @param {number} startX Range start x.
596
+ * @param {number} endX Range end x.
597
+ * @param {object} state Text run state.
598
+ * @returns {{ x: number, y: number }[]}
599
+ */
600
+ function overbarStroke(startX, endX, state) {
601
+ const trim = state.sizeX * overbarTrimRatio
602
+ const y = state.y - state.sizeY * overbarHeightRatio
603
+ return [
604
+ { x: startX + trim, y },
605
+ { x: endX - trim, y }
606
+ ]
607
+ }
608
+
609
+ /**
610
+ * Parses one Hershey-style glyph entry.
611
+ * @param {string} data Glyph source.
612
+ * @returns {object}
613
+ */
614
+ function parseGlyph(data) {
615
+ const glyphStartX = coordinateValue(data[0]) * strokeFontScale
616
+ const glyphEndX = coordinateValue(data[1]) * strokeFontScale
617
+ const strokes = []
618
+ let stroke = []
619
+
620
+ for (let index = 2; index < data.length; index += 2) {
621
+ const xValue = data[index]
622
+ const yValue = data[index + 1]
623
+
624
+ if (xValue === ' ' && yValue === 'R') {
625
+ stroke = []
626
+ continue
627
+ }
628
+
629
+ if (stroke.length === 0) {
630
+ strokes.push(stroke)
631
+ }
632
+
633
+ stroke.push({
634
+ x: coordinateValue(xValue) * strokeFontScale - glyphStartX,
635
+ y: (coordinateValue(yValue) + fontOffset) * strokeFontScale
636
+ })
637
+ }
638
+
639
+ return {
640
+ strokes,
641
+ bounds: glyphBounds(strokes, glyphEndX - glyphStartX)
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Calculates glyph bounds.
647
+ * @param {Array[]} strokes Glyph strokes.
648
+ * @param {number} width Advance width.
649
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
650
+ */
651
+ function glyphBounds(strokes, width) {
652
+ const bounds = { minX: 0, minY: 0, maxX: width, maxY: 0 }
653
+
654
+ strokes.flat().forEach((point) => {
655
+ bounds.minX = Math.min(bounds.minX, point.x)
656
+ bounds.minY = Math.min(bounds.minY, point.y)
657
+ bounds.maxX = Math.max(bounds.maxX, point.x)
658
+ bounds.maxY = Math.max(bounds.maxY, point.y)
659
+ })
660
+
661
+ return bounds
662
+ }
663
+
664
+ /**
665
+ * Converts KiCad encoded glyph coordinates to numbers.
666
+ * @param {string} value Encoded coordinate.
667
+ * @returns {number}
668
+ */
669
+ function coordinateValue(value) {
670
+ return value.charCodeAt(0) - 'R'.charCodeAt(0)
671
+ }