altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,592 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes tiny free-standing dashed note callouts recovered from Altium.
7
+ */
8
+ export class SchematicStandaloneCalloutNormalizer {
9
+ /**
10
+ * Expands and repositions tiny standalone dashed callouts so the frame
11
+ * encloses the nearby circuit and the title sits in the top band.
12
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[]} lines
13
+ * @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
14
+ * @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], texts: { x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[] }}
15
+ */
16
+ static normalize(lines, texts) {
17
+ const dashedFrames =
18
+ SchematicStandaloneCalloutNormalizer.#collectDashedFrameGroups(
19
+ lines
20
+ )
21
+ const consumedLineIndexes = new Set()
22
+ const replacementLines = []
23
+ const replacementTexts = new Map()
24
+
25
+ for (const frame of dashedFrames) {
26
+ const normalizedCallout =
27
+ SchematicStandaloneCalloutNormalizer.#normalizeStandaloneDashedCallout(
28
+ frame,
29
+ lines,
30
+ texts
31
+ )
32
+
33
+ if (!normalizedCallout) {
34
+ continue
35
+ }
36
+
37
+ frame.indexes.forEach((index) => consumedLineIndexes.add(index))
38
+ replacementLines.push(...normalizedCallout.lines)
39
+ replacementTexts.set(
40
+ normalizedCallout.noteIndex,
41
+ normalizedCallout.noteText
42
+ )
43
+ }
44
+
45
+ return {
46
+ lines:
47
+ replacementLines.length > 0
48
+ ? lines
49
+ .filter((_, index) => !consumedLineIndexes.has(index))
50
+ .concat(replacementLines)
51
+ : lines,
52
+ texts:
53
+ replacementTexts.size > 0
54
+ ? texts.map(
55
+ (text, index) => replacementTexts.get(index) || text
56
+ )
57
+ : texts
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Groups dashed line segments into rectangular frame bounds.
63
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines
64
+ * @returns {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], indexes: number[] }[]}
65
+ */
66
+ static #collectDashedFrameGroups(lines) {
67
+ const dashedLines = lines.filter(
68
+ (line) => Number(line.lineStyle || 0) === 1
69
+ )
70
+ const dashedIndexes = lines
71
+ .map((line, index) => ({ line, index }))
72
+ .filter(({ line }) => Number(line.lineStyle || 0) === 1)
73
+ const groups = []
74
+ const visited = new Set()
75
+
76
+ for (let index = 0; index < dashedLines.length; index += 1) {
77
+ if (visited.has(index)) {
78
+ continue
79
+ }
80
+
81
+ const queue = [index]
82
+ const group = []
83
+ visited.add(index)
84
+
85
+ while (queue.length > 0) {
86
+ const currentIndex = queue.shift()
87
+ const currentLine = dashedLines[currentIndex]
88
+ group.push(currentLine)
89
+
90
+ for (
91
+ let candidateIndex = 0;
92
+ candidateIndex < dashedLines.length;
93
+ candidateIndex += 1
94
+ ) {
95
+ if (
96
+ visited.has(candidateIndex) ||
97
+ !SchematicStandaloneCalloutNormalizer.#linesTouch(
98
+ currentLine,
99
+ dashedLines[candidateIndex]
100
+ )
101
+ ) {
102
+ continue
103
+ }
104
+
105
+ visited.add(candidateIndex)
106
+ queue.push(candidateIndex)
107
+ }
108
+ }
109
+
110
+ const minX = Math.min(
111
+ ...group.flatMap((line) => [line.x1, line.x2])
112
+ )
113
+ const maxX = Math.max(
114
+ ...group.flatMap((line) => [line.x1, line.x2])
115
+ )
116
+ const minY = Math.min(
117
+ ...group.flatMap((line) => [line.y1, line.y2])
118
+ )
119
+ const maxY = Math.max(
120
+ ...group.flatMap((line) => [line.y1, line.y2])
121
+ )
122
+
123
+ if (group.length >= 4 && maxX > minX && maxY > minY) {
124
+ groups.push({
125
+ bounds: { minX, minY, maxX, maxY },
126
+ lines: group,
127
+ indexes: group.map(
128
+ (line) =>
129
+ dashedIndexes.find(
130
+ ({ line: candidate }) => candidate === line
131
+ )?.index
132
+ )
133
+ })
134
+ }
135
+ }
136
+
137
+ return groups
138
+ }
139
+
140
+ /**
141
+ * Returns a normalized callout replacement for one tiny free-standing
142
+ * dashed frame, or null when the frame is not the bootstrap-note pattern.
143
+ * @param {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], indexes: number[] }} frame
144
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[]} lines
145
+ * @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
146
+ * @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], noteIndex: number, noteText: { x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' } } | null}
147
+ */
148
+ static #normalizeStandaloneDashedCallout(frame, lines, texts) {
149
+ const frameWidth = frame.bounds.maxX - frame.bounds.minX
150
+ const frameHeight = frame.bounds.maxY - frame.bounds.minY
151
+
152
+ if (
153
+ frameWidth > 120 ||
154
+ frameHeight > 60 ||
155
+ frame.lines.some((line) => line.ownerIndex)
156
+ ) {
157
+ return null
158
+ }
159
+
160
+ const noteIndex = texts.findIndex(
161
+ (text) =>
162
+ text &&
163
+ text.recordType === '4' &&
164
+ !text.ownerIndex &&
165
+ Number(text.fontSize || 0) <= 10 &&
166
+ text.x >= frame.bounds.minX - 2 &&
167
+ text.x <= frame.bounds.maxX + 2 &&
168
+ text.y >= frame.bounds.minY - 2 &&
169
+ text.y <= frame.bounds.maxY + 2
170
+ )
171
+
172
+ if (noteIndex < 0) {
173
+ return null
174
+ }
175
+
176
+ const noteText = texts[noteIndex]
177
+ const relatedOwnerIndexes =
178
+ SchematicStandaloneCalloutNormalizer.#collectNearbyCalloutOwnerIndexes(
179
+ noteText,
180
+ frame.bounds,
181
+ texts
182
+ )
183
+
184
+ if (!relatedOwnerIndexes.size) {
185
+ return null
186
+ }
187
+
188
+ const contentBounds =
189
+ SchematicStandaloneCalloutNormalizer.#collectStandaloneCalloutContentBounds(
190
+ noteText,
191
+ frame.bounds,
192
+ relatedOwnerIndexes,
193
+ lines,
194
+ texts
195
+ )
196
+
197
+ if (!contentBounds) {
198
+ return null
199
+ }
200
+
201
+ const titleBounds =
202
+ SchematicStandaloneCalloutNormalizer.#estimateTextBounds(noteText)
203
+ const horizontalCenter = (contentBounds.minX + contentBounds.maxX) / 2
204
+ const calloutWidth = Math.max(
205
+ frameWidth + 20,
206
+ contentBounds.maxX - contentBounds.minX + 40,
207
+ titleBounds.maxX - titleBounds.minX + 16
208
+ )
209
+ const minX = Math.round(horizontalCenter - calloutWidth / 2)
210
+ const maxX = Math.round(horizontalCenter + calloutWidth / 2)
211
+ const maxY = Math.round(
212
+ Math.max(
213
+ contentBounds.maxY + 18,
214
+ noteText.y + Number(noteText.fontSize || 8) * 2 + 14
215
+ )
216
+ )
217
+ const normalizedNoteText = {
218
+ ...noteText,
219
+ x: Math.round((minX + maxX) / 2),
220
+ y: Math.round(maxY - (Number(noteText.fontSize || 8) + 6)),
221
+ anchor: 'middle'
222
+ }
223
+ const minY = Math.round(contentBounds.minY - 10)
224
+ const prototype = frame.lines[0]
225
+
226
+ return {
227
+ lines: [
228
+ {
229
+ ...prototype,
230
+ x1: minX,
231
+ y1: maxY,
232
+ x2: maxX,
233
+ y2: maxY
234
+ },
235
+ {
236
+ ...prototype,
237
+ x1: maxX,
238
+ y1: maxY,
239
+ x2: maxX,
240
+ y2: minY
241
+ },
242
+ {
243
+ ...prototype,
244
+ x1: maxX,
245
+ y1: minY,
246
+ x2: minX,
247
+ y2: minY
248
+ },
249
+ {
250
+ ...prototype,
251
+ x1: minX,
252
+ y1: minY,
253
+ x2: minX,
254
+ y2: maxY
255
+ }
256
+ ],
257
+ noteIndex,
258
+ noteText: normalizedNoteText
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Collects owner indexes for designator/value texts immediately below a
264
+ * tiny standalone dashed note.
265
+ * @param {{ x: number, y: number }} noteText
266
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} frameBounds
267
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string }[]} texts
268
+ * @returns {Set<string>}
269
+ */
270
+ static #collectNearbyCalloutOwnerIndexes(noteText, frameBounds, texts) {
271
+ const relatedTexts = texts.filter((text) => {
272
+ const normalizedName = String(text?.name || '')
273
+ .trim()
274
+ .toLowerCase()
275
+
276
+ return (
277
+ text &&
278
+ text.ownerIndex &&
279
+ (normalizedName === 'designator' ||
280
+ normalizedName === 'value') &&
281
+ text.x >= frameBounds.minX - 20 &&
282
+ text.x <= noteText.x + 40 &&
283
+ text.y >= frameBounds.minY - 20 &&
284
+ text.y <= frameBounds.maxY + 20
285
+ )
286
+ })
287
+
288
+ return new Set(relatedTexts.map((text) => text.ownerIndex))
289
+ }
290
+
291
+ /**
292
+ * Collects the visible content bounds a standalone dashed callout should
293
+ * enclose.
294
+ * @param {{ x: number, y: number }} noteText
295
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} frameBounds
296
+ * @param {Set<string>} relatedOwnerIndexes
297
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number, ownerIndex?: string }[]} lines
298
+ * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
299
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
300
+ */
301
+ static #collectStandaloneCalloutContentBounds(
302
+ noteText,
303
+ frameBounds,
304
+ relatedOwnerIndexes,
305
+ lines,
306
+ texts
307
+ ) {
308
+ const localWindow = {
309
+ minX: frameBounds.minX - 20,
310
+ minY: frameBounds.minY - 6,
311
+ maxX: frameBounds.maxX + 20,
312
+ maxY: frameBounds.maxY + 10
313
+ }
314
+ const contentLines = lines.filter((line) => {
315
+ if (Number(line.lineStyle || 0) === 1) {
316
+ return false
317
+ }
318
+
319
+ const lineBounds = {
320
+ minX: Math.min(line.x1, line.x2),
321
+ minY: Math.min(line.y1, line.y2),
322
+ maxX: Math.max(line.x1, line.x2),
323
+ maxY: Math.max(line.y1, line.y2)
324
+ }
325
+
326
+ if (
327
+ !SchematicStandaloneCalloutNormalizer.#boundsOverlap(
328
+ lineBounds,
329
+ localWindow
330
+ )
331
+ ) {
332
+ return false
333
+ }
334
+
335
+ if (relatedOwnerIndexes.has(String(line.ownerIndex || ''))) {
336
+ return true
337
+ }
338
+
339
+ return (
340
+ !line.ownerIndex &&
341
+ SchematicStandaloneCalloutNormalizer.#lineEndpointsStayWithinBounds(
342
+ line,
343
+ localWindow
344
+ )
345
+ )
346
+ })
347
+ const contentTexts = texts.filter((text) => {
348
+ if (!text || text.recordType === '4') {
349
+ return false
350
+ }
351
+
352
+ if (
353
+ text.ownerIndex &&
354
+ !relatedOwnerIndexes.has(String(text.ownerIndex || ''))
355
+ ) {
356
+ return false
357
+ }
358
+
359
+ const normalizedName = String(text.name || '')
360
+ .trim()
361
+ .toLowerCase()
362
+ if (
363
+ text.ownerIndex &&
364
+ (normalizedName === 'value' || normalizedName === 'comment') &&
365
+ Number(text.y) < frameBounds.minY
366
+ ) {
367
+ return false
368
+ }
369
+
370
+ return SchematicStandaloneCalloutNormalizer.#boundsOverlap(
371
+ SchematicStandaloneCalloutNormalizer.#estimateTextBounds(text),
372
+ localWindow
373
+ )
374
+ })
375
+
376
+ return SchematicStandaloneCalloutNormalizer.#collectPrimitiveBounds(
377
+ contentLines,
378
+ contentTexts
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Collects one union bounds object from line and text primitives.
384
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
385
+ * @param {{ x: number, y: number, text: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
386
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
387
+ */
388
+ static #collectPrimitiveBounds(lines, texts) {
389
+ if (!lines.length && !texts.length) {
390
+ return null
391
+ }
392
+
393
+ const bounds = {
394
+ minX: Number.POSITIVE_INFINITY,
395
+ minY: Number.POSITIVE_INFINITY,
396
+ maxX: Number.NEGATIVE_INFINITY,
397
+ maxY: Number.NEGATIVE_INFINITY
398
+ }
399
+
400
+ for (const line of lines) {
401
+ bounds.minX = Math.min(bounds.minX, line.x1, line.x2)
402
+ bounds.minY = Math.min(bounds.minY, line.y1, line.y2)
403
+ bounds.maxX = Math.max(bounds.maxX, line.x1, line.x2)
404
+ bounds.maxY = Math.max(bounds.maxY, line.y1, line.y2)
405
+ }
406
+
407
+ for (const text of texts) {
408
+ const textBounds =
409
+ SchematicStandaloneCalloutNormalizer.#estimateTextBounds(text)
410
+ bounds.minX = Math.min(bounds.minX, textBounds.minX)
411
+ bounds.minY = Math.min(bounds.minY, textBounds.minY)
412
+ bounds.maxX = Math.max(bounds.maxX, textBounds.maxX)
413
+ bounds.maxY = Math.max(bounds.maxY, textBounds.maxY)
414
+ }
415
+
416
+ return bounds
417
+ }
418
+
419
+ /**
420
+ * Returns true when both endpoints stay inside the provided local window.
421
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
422
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
423
+ * @returns {boolean}
424
+ */
425
+ static #lineEndpointsStayWithinBounds(line, bounds) {
426
+ return (
427
+ (SchematicStandaloneCalloutNormalizer.#boundsContainPoint(bounds, {
428
+ x: line.x1,
429
+ y: line.y1
430
+ }) ||
431
+ false) &&
432
+ SchematicStandaloneCalloutNormalizer.#boundsContainPoint(bounds, {
433
+ x: line.x2,
434
+ y: line.y2
435
+ })
436
+ )
437
+ }
438
+
439
+ /**
440
+ * Returns true when one point lies inside axis-aligned bounds.
441
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
442
+ * @param {{ x: number, y: number }} point
443
+ * @returns {boolean}
444
+ */
445
+ static #boundsContainPoint(bounds, point) {
446
+ return (
447
+ point.x >= bounds.minX &&
448
+ point.x <= bounds.maxX &&
449
+ point.y >= bounds.minY &&
450
+ point.y <= bounds.maxY
451
+ )
452
+ }
453
+
454
+ /**
455
+ * Approximates schematic text bounds using the serif width factors already
456
+ * used by the SVG renderers.
457
+ * @param {{ x: number, y: number, text: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }} text
458
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
459
+ */
460
+ static #estimateTextBounds(text) {
461
+ const fontSize = Number(text.fontSize || 10)
462
+ const width = SchematicStandaloneCalloutNormalizer.#estimateTextWidth(
463
+ text.text,
464
+ fontSize
465
+ )
466
+ let minX = Number(text.x)
467
+ let maxX = Number(text.x)
468
+
469
+ if (text.anchor === 'middle') {
470
+ minX -= width / 2
471
+ maxX += width / 2
472
+ } else if (text.anchor === 'end') {
473
+ minX -= width
474
+ } else {
475
+ maxX += width
476
+ }
477
+
478
+ return {
479
+ minX,
480
+ minY: Number(text.y) - fontSize * 0.4,
481
+ maxX,
482
+ maxY: Number(text.y) + fontSize * 0.8
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Estimates serif text width for one recovered schematic label.
488
+ * @param {string} text
489
+ * @param {number} fontSize
490
+ * @returns {number}
491
+ */
492
+ static #estimateTextWidth(text, fontSize) {
493
+ let width = 0
494
+
495
+ for (const character of String(text || '')) {
496
+ width +=
497
+ SchematicStandaloneCalloutNormalizer.#measureCharacterWidth(
498
+ character
499
+ ) * fontSize
500
+ }
501
+
502
+ return width
503
+ }
504
+
505
+ /**
506
+ * Returns a rough Times New Roman width factor for one character.
507
+ * @param {string} character
508
+ * @returns {number}
509
+ */
510
+ static #measureCharacterWidth(character) {
511
+ if (/\s/.test(character)) return 0.32
512
+ if (/[.,;:!|]/.test(character)) return 0.24
513
+ if (/[()[\]{}]/.test(character)) return 0.32
514
+ if (/[-+/\\]/.test(character)) return 0.36
515
+ if (/[MW@#%&]/.test(character)) return 0.82
516
+ if (/[A-Z]/.test(character)) return 0.62
517
+ if (/[a-z0-9]/.test(character)) return 0.5
518
+ if (/[^ -~]/.test(character)) return 0.92
519
+
520
+ return 0.56
521
+ }
522
+
523
+ /**
524
+ * Returns true when two axis-aligned bounds overlap.
525
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left
526
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right
527
+ * @returns {boolean}
528
+ */
529
+ static #boundsOverlap(left, right) {
530
+ return !(
531
+ left.maxX < right.minX ||
532
+ left.minX > right.maxX ||
533
+ left.maxY < right.minY ||
534
+ left.minY > right.maxY
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Clips one axis-aligned bounds object to an enclosing window.
540
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
541
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} window
542
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
543
+ */
544
+ static #clipBoundsToWindow(bounds, window) {
545
+ return {
546
+ minX: Math.max(bounds.minX, window.minX),
547
+ minY: Math.max(bounds.minY, window.minY),
548
+ maxX: Math.min(bounds.maxX, window.maxX),
549
+ maxY: Math.min(bounds.maxY, window.maxY)
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Returns true when two line segments touch at any endpoint.
555
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} left
556
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} right
557
+ * @returns {boolean}
558
+ */
559
+ static #linesTouch(left, right) {
560
+ return (
561
+ SchematicStandaloneCalloutNormalizer.#pointsMatch(
562
+ { x: left.x1, y: left.y1 },
563
+ { x: right.x1, y: right.y1 }
564
+ ) ||
565
+ SchematicStandaloneCalloutNormalizer.#pointsMatch(
566
+ { x: left.x1, y: left.y1 },
567
+ { x: right.x2, y: right.y2 }
568
+ ) ||
569
+ SchematicStandaloneCalloutNormalizer.#pointsMatch(
570
+ { x: left.x2, y: left.y2 },
571
+ { x: right.x1, y: right.y1 }
572
+ ) ||
573
+ SchematicStandaloneCalloutNormalizer.#pointsMatch(
574
+ { x: left.x2, y: left.y2 },
575
+ { x: right.x2, y: right.y2 }
576
+ )
577
+ )
578
+ }
579
+
580
+ /**
581
+ * Returns true when two points coincide within schematic tolerance.
582
+ * @param {{ x: number, y: number }} left
583
+ * @param {{ x: number, y: number }} right
584
+ * @returns {boolean}
585
+ */
586
+ static #pointsMatch(left, right) {
587
+ return (
588
+ Math.abs(Number(left.x) - Number(right.x)) <= 2 &&
589
+ Math.abs(Number(left.y) - Number(right.y)) <= 2
590
+ )
591
+ }
592
+ }