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,808 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes edge-facing footprint documentation glyphs so repeated mirrored
7
+ * variants open toward the nearest board edge in the 2D PCB renderer.
8
+ */
9
+ export class PcbEdgeFacingGlyphNormalizer {
10
+ static #FULL_CIRCLE_EPSILON = 0.001
11
+ static #EDGE_GLYPH_CONNECTION_TOLERANCE = 4
12
+ static #EDGE_GLYPH_MIN_TRACK_COUNT = 5
13
+ static #EDGE_GLYPH_TIP_TRACK_LENGTH_RATIO = 0.7
14
+ static #EDGE_GLYPH_CENTER_TOLERANCE = 1.5
15
+ static #EDGE_GLYPH_PROXIMITY_RATIO = 0.2
16
+ static #MARKER_PROXIMITY_MULTIPLIER = 3
17
+
18
+ /**
19
+ * Normalizes repeated edge-facing documentation glyphs so their opening
20
+ * stays on the board edge even when the authored primitive cluster is
21
+ * mirrored inward.
22
+ * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
23
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
24
+ * @param {{ preferMarkers?: boolean }} [options]
25
+ * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
26
+ */
27
+ static normalize(footprintPrimitives, outline, options = {}) {
28
+ const normalizedTracks = (footprintPrimitives?.tracks || []).map(
29
+ (track) => ({ ...track })
30
+ )
31
+ const normalizedArcs = (footprintPrimitives?.arcs || []).map((arc) => ({
32
+ ...arc
33
+ }))
34
+ const groups = PcbEdgeFacingGlyphNormalizer.#collectGroups(
35
+ normalizedTracks,
36
+ normalizedArcs
37
+ )
38
+
39
+ for (const group of groups) {
40
+ const transform = PcbEdgeFacingGlyphNormalizer.#resolveTransform(
41
+ group,
42
+ normalizedTracks,
43
+ normalizedArcs
44
+ )
45
+
46
+ if (!transform) {
47
+ continue
48
+ }
49
+
50
+ if (transform.kind === 'arc-half-flip') {
51
+ group.arcIndexes.forEach((arcIndex) => {
52
+ normalizedArcs[arcIndex] =
53
+ PcbEdgeFacingGlyphNormalizer.#flipArcHalf(
54
+ normalizedArcs[arcIndex]
55
+ )
56
+ })
57
+ continue
58
+ }
59
+ }
60
+
61
+ return {
62
+ fills: footprintPrimitives?.fills || [],
63
+ tracks: normalizedTracks,
64
+ arcs: normalizedArcs
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Normalizes glyphs using only the nearest board edge so 3D silkscreen
70
+ * detail does not overreact to nearby circular markers on other features.
71
+ * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
72
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
73
+ * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
74
+ */
75
+ static normalizeForBoardEdge(footprintPrimitives, outline) {
76
+ return PcbEdgeFacingGlyphNormalizer.normalize(
77
+ footprintPrimitives,
78
+ outline
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Collects connected non-circular footprint glyph groups that could need
84
+ * edge-facing orientation cleanup.
85
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }[]} tracks
86
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
87
+ * @returns {{ trackIndexes: number[], arcIndexes: number[], minX: number, maxX: number, minY: number, maxY: number }[]}
88
+ */
89
+ static #collectGroups(tracks, arcs) {
90
+ const items = [
91
+ ...tracks.map((track, trackIndex) => ({
92
+ kind: 'track',
93
+ trackIndex,
94
+ bounds: PcbEdgeFacingGlyphNormalizer.#buildTrackBounds(track)
95
+ })),
96
+ ...arcs
97
+ .map((arc, arcIndex) => ({
98
+ kind: 'arc',
99
+ arcIndex,
100
+ arc,
101
+ bounds: PcbEdgeFacingGlyphNormalizer.#buildArcBounds(arc)
102
+ }))
103
+ .filter(
104
+ (item) =>
105
+ !PcbEdgeFacingGlyphNormalizer.#isFullCircleArc(item.arc)
106
+ )
107
+ ]
108
+ const visited = new Array(items.length).fill(false)
109
+ const groups = []
110
+
111
+ for (let index = 0; index < items.length; index += 1) {
112
+ if (visited[index]) {
113
+ continue
114
+ }
115
+
116
+ const queue = [index]
117
+ const trackIndexes = []
118
+ const arcIndexes = []
119
+ let minX = Number.POSITIVE_INFINITY
120
+ let maxX = Number.NEGATIVE_INFINITY
121
+ let minY = Number.POSITIVE_INFINITY
122
+ let maxY = Number.NEGATIVE_INFINITY
123
+ visited[index] = true
124
+
125
+ while (queue.length) {
126
+ const currentIndex = queue.pop()
127
+ const currentItem = items[currentIndex]
128
+ minX = Math.min(minX, currentItem.bounds.minX)
129
+ maxX = Math.max(maxX, currentItem.bounds.maxX)
130
+ minY = Math.min(minY, currentItem.bounds.minY)
131
+ maxY = Math.max(maxY, currentItem.bounds.maxY)
132
+
133
+ if (currentItem.kind === 'track') {
134
+ trackIndexes.push(currentItem.trackIndex)
135
+ } else {
136
+ arcIndexes.push(currentItem.arcIndex)
137
+ }
138
+
139
+ for (
140
+ let nextIndex = 0;
141
+ nextIndex < items.length;
142
+ nextIndex += 1
143
+ ) {
144
+ if (visited[nextIndex]) {
145
+ continue
146
+ }
147
+
148
+ if (
149
+ PcbEdgeFacingGlyphNormalizer.#boundsIntersect(
150
+ currentItem.bounds,
151
+ items[nextIndex].bounds
152
+ )
153
+ ) {
154
+ visited[nextIndex] = true
155
+ queue.push(nextIndex)
156
+ }
157
+ }
158
+ }
159
+
160
+ groups.push({
161
+ trackIndexes,
162
+ arcIndexes,
163
+ minX,
164
+ maxX,
165
+ minY,
166
+ maxY
167
+ })
168
+ }
169
+
170
+ return groups
171
+ }
172
+
173
+ /**
174
+ * Resolves whether one connected screw glyph needs its semicircular head
175
+ * moved onto the same side as the screw tip while keeping the authored
176
+ * shaft geometry unchanged.
177
+ * @param {{ trackIndexes: number[], arcIndexes: number[], minX: number, maxX: number, minY: number, maxY: number }} group
178
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }[]} tracks
179
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
180
+ * @returns {{ kind: 'arc-half-flip' } | null}
181
+ */
182
+ static #resolveTransform(group, tracks, arcs) {
183
+ if (
184
+ group.trackIndexes.length <
185
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_MIN_TRACK_COUNT ||
186
+ group.arcIndexes.length !== 1
187
+ ) {
188
+ return null
189
+ }
190
+
191
+ const arc = arcs[group.arcIndexes[0]]
192
+ if (
193
+ Math.abs(
194
+ Math.abs(
195
+ PcbEdgeFacingGlyphNormalizer.#resolveSweepDelta(
196
+ Number(arc.startAngle || 0),
197
+ Number(arc.endAngle || 0)
198
+ )
199
+ ) - 180
200
+ ) > PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
201
+ ) {
202
+ return null
203
+ }
204
+ const tipSide = PcbEdgeFacingGlyphNormalizer.#resolveTipSide(
205
+ group,
206
+ tracks,
207
+ arc
208
+ )
209
+ if (!tipSide) {
210
+ return null
211
+ }
212
+ const currentHeadSide =
213
+ PcbEdgeFacingGlyphNormalizer.#resolveSemicircleSide(arc)
214
+ const desiredHeadSide = tipSide
215
+
216
+ if (!currentHeadSide || currentHeadSide === desiredHeadSide) {
217
+ return null
218
+ }
219
+
220
+ return { kind: 'arc-half-flip' }
221
+ }
222
+
223
+ /**
224
+ * Moves one semicircular screw head onto the opposite circle half while
225
+ * preserving its authored endpoints and center position.
226
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
227
+ * @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
228
+ */
229
+ static #flipArcHalf(arc) {
230
+ const endAngle = Number(arc.endAngle || 0)
231
+ const rawDelta = endAngle - Number(arc.startAngle || 0)
232
+
233
+ return {
234
+ ...arc,
235
+ endAngle:
236
+ rawDelta > PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
237
+ ? endAngle - 360
238
+ : endAngle + 360
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Resolves which side one screw tip points toward from the two longest
244
+ * tracks meeting at the head center.
245
+ * @param {{ trackIndexes: number[] }} group
246
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }[]} tracks
247
+ * @param {{ x: number, y: number, radius: number }} arc
248
+ * @returns {'left' | 'right' | 'top' | 'bottom' | null}
249
+ */
250
+ static #resolveTipSide(group, tracks, arc) {
251
+ const centerX = Number(arc.x || 0)
252
+ const centerY = Number(arc.y || 0)
253
+ const minimumLength = Math.max(
254
+ Number(arc.radius || 0) *
255
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_TIP_TRACK_LENGTH_RATIO,
256
+ 18
257
+ )
258
+ const tipVectors = group.trackIndexes
259
+ .map((trackIndex) =>
260
+ PcbEdgeFacingGlyphNormalizer.#resolveTipVector(
261
+ tracks[trackIndex],
262
+ centerX,
263
+ centerY
264
+ )
265
+ )
266
+ .filter(Boolean)
267
+ .filter((vector) => vector.length >= minimumLength)
268
+ .sort((left, right) => right.length - left.length)
269
+ .slice(0, 2)
270
+
271
+ if (!tipVectors.length) {
272
+ return null
273
+ }
274
+
275
+ const averageVector = tipVectors.reduce(
276
+ (sum, vector) => ({
277
+ x: sum.x + vector.x,
278
+ y: sum.y + vector.y
279
+ }),
280
+ { x: 0, y: 0 }
281
+ )
282
+
283
+ if (
284
+ Math.abs(averageVector.x) <=
285
+ PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON &&
286
+ Math.abs(averageVector.y) <=
287
+ PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
288
+ ) {
289
+ return null
290
+ }
291
+
292
+ if (Math.abs(averageVector.x) >= Math.abs(averageVector.y)) {
293
+ return averageVector.x >= 0 ? 'right' : 'left'
294
+ }
295
+
296
+ return averageVector.y >= 0 ? 'bottom' : 'top'
297
+ }
298
+
299
+ /**
300
+ * Resolves one tip vector from a center-connected screw track.
301
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} track
302
+ * @param {number} centerX
303
+ * @param {number} centerY
304
+ * @returns {{ x: number, y: number, length: number } | null}
305
+ */
306
+ static #resolveTipVector(track, centerX, centerY) {
307
+ if (!track) {
308
+ return null
309
+ }
310
+
311
+ const firstPoint = {
312
+ x: Number(track.x1),
313
+ y: Number(track.y1)
314
+ }
315
+ const secondPoint = {
316
+ x: Number(track.x2),
317
+ y: Number(track.y2)
318
+ }
319
+ const firstDistance = Math.hypot(
320
+ firstPoint.x - centerX,
321
+ firstPoint.y - centerY
322
+ )
323
+ const secondDistance = Math.hypot(
324
+ secondPoint.x - centerX,
325
+ secondPoint.y - centerY
326
+ )
327
+ const minimumDistance =
328
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CENTER_TOLERANCE
329
+
330
+ if (
331
+ firstDistance > minimumDistance &&
332
+ secondDistance > minimumDistance
333
+ ) {
334
+ return null
335
+ }
336
+
337
+ const farPoint =
338
+ firstDistance > secondDistance ? firstPoint : secondPoint
339
+
340
+ return {
341
+ x: farPoint.x - centerX,
342
+ y: farPoint.y - centerY,
343
+ length: Math.hypot(farPoint.x - centerX, farPoint.y - centerY)
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Resolves which circle half one semicircular screw head currently occupies.
349
+ * @param {{ startAngle: number, endAngle: number }} arc
350
+ * @returns {'left' | 'right' | 'top' | 'bottom' | null}
351
+ */
352
+ static #resolveSemicircleSide(arc) {
353
+ const delta = PcbEdgeFacingGlyphNormalizer.#resolveSweepDelta(
354
+ Number(arc.startAngle || 0),
355
+ Number(arc.endAngle || 0)
356
+ )
357
+
358
+ if (
359
+ Math.abs(Math.abs(delta) - 180) >
360
+ PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
361
+ ) {
362
+ return null
363
+ }
364
+
365
+ const midpointAngle =
366
+ (Number(arc.startAngle || 0) + delta / 2) * (Math.PI / 180)
367
+ const x = Math.cos(midpointAngle)
368
+ const y = Math.sin(midpointAngle)
369
+
370
+ if (Math.abs(x) >= Math.abs(y)) {
371
+ return x >= 0 ? 'right' : 'left'
372
+ }
373
+
374
+ return y >= 0 ? 'bottom' : 'top'
375
+ }
376
+
377
+ /**
378
+ * Resolves whether one glyph should mirror horizontally or vertically to
379
+ * face its nearest board edge.
380
+ * @param {'left' | 'right' | 'top' | 'bottom'} edge
381
+ * @param {{ x: number, y: number }} arc
382
+ * @param {number} centerX
383
+ * @param {number} centerY
384
+ * @returns {{ axis: 'horizontal' | 'vertical', value: number } | null}
385
+ */
386
+ static #resolveEdgeTransform(edge, arc, centerX, centerY) {
387
+ if (edge === 'left' && Number(arc.x) > centerX) {
388
+ return {
389
+ axis: 'horizontal',
390
+ value: centerX
391
+ }
392
+ }
393
+
394
+ if (edge === 'right' && Number(arc.x) < centerX) {
395
+ return {
396
+ axis: 'horizontal',
397
+ value: centerX
398
+ }
399
+ }
400
+
401
+ if (edge === 'top' && Number(arc.y) > centerY) {
402
+ return {
403
+ axis: 'vertical',
404
+ value: centerY
405
+ }
406
+ }
407
+
408
+ if (edge === 'bottom' && Number(arc.y) < centerY) {
409
+ return {
410
+ axis: 'vertical',
411
+ value: centerY
412
+ }
413
+ }
414
+
415
+ return null
416
+ }
417
+
418
+ /**
419
+ * Resolves the nearest adjacent full-circle marker for one glyph group.
420
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} group
421
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
422
+ * @returns {{ x: number, y: number } | null}
423
+ */
424
+ static #resolveNearestFullCircleMarker(group, arcs) {
425
+ const centerX = (group.minX + group.maxX) / 2
426
+ const centerY = (group.minY + group.maxY) / 2
427
+ const maxSpan = Math.max(
428
+ group.maxX - group.minX,
429
+ group.maxY - group.minY,
430
+ 1
431
+ )
432
+ const maxDistance =
433
+ maxSpan * PcbEdgeFacingGlyphNormalizer.#MARKER_PROXIMITY_MULTIPLIER
434
+ let nearestMarker = null
435
+ let nearestDistance = Number.POSITIVE_INFINITY
436
+
437
+ for (const arc of arcs) {
438
+ if (!PcbEdgeFacingGlyphNormalizer.#isFullCircleArc(arc)) {
439
+ continue
440
+ }
441
+
442
+ const deltaX = Number(arc.x) - centerX
443
+ const deltaY = Number(arc.y) - centerY
444
+ const distance = Math.hypot(deltaX, deltaY)
445
+
446
+ if (distance > maxDistance || distance >= nearestDistance) {
447
+ continue
448
+ }
449
+
450
+ nearestMarker = {
451
+ x: Number(arc.x),
452
+ y: Number(arc.y)
453
+ }
454
+ nearestDistance = distance
455
+ }
456
+
457
+ return nearestMarker
458
+ }
459
+
460
+ /**
461
+ * Resolves whether one glyph should mirror to face its adjacent full-circle
462
+ * marker.
463
+ * @param {{ x: number, y: number }} marker
464
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }} arc
465
+ * @param {number} centerX
466
+ * @param {number} centerY
467
+ * @returns {{ axis: 'horizontal' | 'vertical', value: number } | null}
468
+ */
469
+ static #resolveMarkerTransform(marker, arc, centerX, centerY) {
470
+ const deltaX = Number(marker.x) - centerX
471
+ const deltaY = Number(marker.y) - centerY
472
+
473
+ if (Math.abs(deltaX) >= Math.abs(deltaY)) {
474
+ if (deltaX < 0 && Number(arc.x) > centerX) {
475
+ return {
476
+ axis: 'horizontal',
477
+ value: centerX
478
+ }
479
+ }
480
+
481
+ if (deltaX > 0 && Number(arc.x) < centerX) {
482
+ return {
483
+ axis: 'horizontal',
484
+ value: centerX
485
+ }
486
+ }
487
+
488
+ return null
489
+ }
490
+
491
+ if (deltaY < 0 && Number(arc.y) > centerY) {
492
+ return {
493
+ axis: 'vertical',
494
+ value: centerY
495
+ }
496
+ }
497
+
498
+ if (deltaY > 0 && Number(arc.y) < centerY) {
499
+ return {
500
+ axis: 'vertical',
501
+ value: centerY
502
+ }
503
+ }
504
+
505
+ return null
506
+ }
507
+
508
+ /**
509
+ * Mirrors one track horizontally around a local cluster axis.
510
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
511
+ * @param {number} axisX
512
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
513
+ */
514
+ static #mirrorTrackHorizontally(track, axisX) {
515
+ return PcbEdgeFacingGlyphNormalizer.#normalizeTrackDirection({
516
+ ...track,
517
+ x1: axisX * 2 - Number(track.x1),
518
+ x2: axisX * 2 - Number(track.x2)
519
+ })
520
+ }
521
+
522
+ /**
523
+ * Mirrors one track vertically around a local cluster axis.
524
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
525
+ * @param {number} axisY
526
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
527
+ */
528
+ static #mirrorTrackVertically(track, axisY) {
529
+ return PcbEdgeFacingGlyphNormalizer.#normalizeTrackDirection({
530
+ ...track,
531
+ y1: axisY * 2 - Number(track.y1),
532
+ y2: axisY * 2 - Number(track.y2)
533
+ })
534
+ }
535
+
536
+ /**
537
+ * Mirrors one arc horizontally around a local cluster axis.
538
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
539
+ * @param {number} axisX
540
+ * @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
541
+ */
542
+ static #mirrorArcHorizontally(arc, axisX) {
543
+ return {
544
+ ...arc,
545
+ x: axisX * 2 - Number(arc.x),
546
+ startAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
547
+ 180 - Number(arc.endAngle)
548
+ ),
549
+ endAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
550
+ 180 - Number(arc.startAngle)
551
+ )
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Mirrors one arc vertically around a local cluster axis.
557
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
558
+ * @param {number} axisY
559
+ * @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
560
+ */
561
+ static #mirrorArcVertically(arc, axisY) {
562
+ return {
563
+ ...arc,
564
+ y: axisY * 2 - Number(arc.y),
565
+ startAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
566
+ 360 - Number(arc.endAngle)
567
+ ),
568
+ endAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
569
+ 360 - Number(arc.startAngle)
570
+ )
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Returns one stroke-aware bounds envelope for a documentation track.
576
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }} track
577
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
578
+ */
579
+ static #buildTrackBounds(track) {
580
+ const halfWidth =
581
+ Math.max(Number(track.width || 0), 1) / 2 +
582
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CONNECTION_TOLERANCE
583
+
584
+ return {
585
+ minX: Math.min(Number(track.x1), Number(track.x2)) - halfWidth,
586
+ maxX: Math.max(Number(track.x1), Number(track.x2)) + halfWidth,
587
+ minY: Math.min(Number(track.y1), Number(track.y2)) - halfWidth,
588
+ maxY: Math.max(Number(track.y1), Number(track.y2)) + halfWidth
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Returns one stroke-aware bounds envelope for a documentation arc.
594
+ * @param {{ x: number, y: number, radius: number, width?: number }} arc
595
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
596
+ */
597
+ static #buildArcBounds(arc) {
598
+ const radius =
599
+ Math.max(Number(arc.radius || 0), 0) +
600
+ Math.max(Number(arc.width || 0), 1) / 2 +
601
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CONNECTION_TOLERANCE
602
+
603
+ return {
604
+ minX: Number(arc.x) - radius,
605
+ maxX: Number(arc.x) + radius,
606
+ minY: Number(arc.y) - radius,
607
+ maxY: Number(arc.y) + radius
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Returns true when two stroke-aware primitive envelopes overlap.
613
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} left
614
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} right
615
+ * @returns {boolean}
616
+ */
617
+ static #boundsIntersect(left, right) {
618
+ return !(
619
+ left.maxX < right.minX ||
620
+ left.minX > right.maxX ||
621
+ left.maxY < right.minY ||
622
+ left.minY > right.maxY
623
+ )
624
+ }
625
+
626
+ /**
627
+ * Resolves the nearest relevant board edge for one connected glyph bounds
628
+ * box. Corner-adjacent glyphs use their own opening axis first so a
629
+ * left-edge screw near the top border still resolves against the left
630
+ * board edge instead of the closer top/bottom corner distance.
631
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
632
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
633
+ * @param {{ x: number, y: number }} arc
634
+ * @param {number} centerX
635
+ * @param {number} centerY
636
+ * @returns {{ edge: 'left' | 'right' | 'top' | 'bottom', distance: number }}
637
+ */
638
+ static #resolveNearestOutlineEdge(bounds, outline, arc, centerX, centerY) {
639
+ const outlineMaxX =
640
+ Number(outline?.minX || 0) + Number(outline?.widthMil || 0)
641
+ const outlineMaxY =
642
+ Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
643
+ const horizontalDistances = [
644
+ {
645
+ edge: 'left',
646
+ distance: Math.max(bounds.minX - Number(outline?.minX || 0), 0)
647
+ },
648
+ {
649
+ edge: 'right',
650
+ distance: Math.max(outlineMaxX - bounds.maxX, 0)
651
+ }
652
+ ]
653
+ const verticalDistances = [
654
+ {
655
+ edge: 'top',
656
+ distance: Math.max(bounds.minY - Number(outline?.minY || 0), 0)
657
+ },
658
+ {
659
+ edge: 'bottom',
660
+ distance: Math.max(outlineMaxY - bounds.maxY, 0)
661
+ }
662
+ ]
663
+
664
+ horizontalDistances.sort(
665
+ (left, right) => left.distance - right.distance
666
+ )
667
+ verticalDistances.sort((left, right) => left.distance - right.distance)
668
+ const axis = PcbEdgeFacingGlyphNormalizer.#resolveGlyphAxis(
669
+ bounds,
670
+ arc,
671
+ centerX,
672
+ centerY
673
+ )
674
+
675
+ if (axis === 'horizontal') {
676
+ return horizontalDistances[0]
677
+ }
678
+
679
+ if (axis === 'vertical') {
680
+ return verticalDistances[0]
681
+ }
682
+
683
+ const distances = [horizontalDistances[0], verticalDistances[0]]
684
+ distances.sort((left, right) => left.distance - right.distance)
685
+ return distances[0]
686
+ }
687
+
688
+ /**
689
+ * Resolves the primary opening axis for one documentation glyph from the
690
+ * arc position and, when that is ambiguous, from the group's overall span.
691
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
692
+ * @param {{ x: number, y: number }} arc
693
+ * @param {number} centerX
694
+ * @param {number} centerY
695
+ * @returns {'horizontal' | 'vertical' | null}
696
+ */
697
+ static #resolveGlyphAxis(bounds, arc, centerX, centerY) {
698
+ const deltaX = Math.abs(Number(arc?.x || 0) - centerX)
699
+ const deltaY = Math.abs(Number(arc?.y || 0) - centerY)
700
+
701
+ if (
702
+ deltaX >
703
+ deltaY + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
704
+ ) {
705
+ return 'horizontal'
706
+ }
707
+
708
+ if (
709
+ deltaY >
710
+ deltaX + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
711
+ ) {
712
+ return 'vertical'
713
+ }
714
+
715
+ const width = Math.max(Number(bounds.maxX) - Number(bounds.minX), 0)
716
+ const height = Math.max(Number(bounds.maxY) - Number(bounds.minY), 0)
717
+
718
+ if (
719
+ width >
720
+ height + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
721
+ ) {
722
+ return 'horizontal'
723
+ }
724
+
725
+ if (
726
+ height >
727
+ width + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
728
+ ) {
729
+ return 'vertical'
730
+ }
731
+
732
+ return null
733
+ }
734
+
735
+ /**
736
+ * Returns true when one documentation arc is effectively a full circle.
737
+ * @param {{ startAngle: number, endAngle: number }} arc
738
+ * @returns {boolean}
739
+ */
740
+ static #isFullCircleArc(arc) {
741
+ const delta = Number(arc.endAngle || 0) - Number(arc.startAngle || 0)
742
+
743
+ return (
744
+ Math.abs(delta) <=
745
+ PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON ||
746
+ Math.abs(delta) >=
747
+ 360 - PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
748
+ )
749
+ }
750
+
751
+ /**
752
+ * Resolves one arc sweep delta onto the short wrapped direction used by
753
+ * the PCB renderers.
754
+ * @param {number} startAngle
755
+ * @param {number} endAngle
756
+ * @returns {number}
757
+ */
758
+ static #resolveSweepDelta(startAngle, endAngle) {
759
+ const rawDelta = Number(endAngle || 0) - Number(startAngle || 0)
760
+ let normalizedDelta = ((rawDelta + 540) % 360) - 180
761
+
762
+ if (
763
+ Math.abs(normalizedDelta + 180) <=
764
+ PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON &&
765
+ rawDelta > 0
766
+ ) {
767
+ normalizedDelta = 180
768
+ }
769
+
770
+ return normalizedDelta
771
+ }
772
+
773
+ /**
774
+ * Normalizes one mirrored track direction into a stable left-to-right or
775
+ * top-to-bottom ordering for deterministic SVG output.
776
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
777
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
778
+ */
779
+ static #normalizeTrackDirection(track) {
780
+ const x1 = Number(track.x1)
781
+ const y1 = Number(track.y1)
782
+ const x2 = Number(track.x2)
783
+ const y2 = Number(track.y2)
784
+
785
+ if (x1 < x2 || (Math.abs(x1 - x2) <= 0.001 && y1 <= y2)) {
786
+ return track
787
+ }
788
+
789
+ return {
790
+ ...track,
791
+ x1: x2,
792
+ y1: y2,
793
+ x2: x1,
794
+ y2: y1
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Normalizes one angle into the range [0, 360).
800
+ * @param {number} angle
801
+ * @returns {number}
802
+ */
803
+ static #normalizeAngle(angle) {
804
+ const normalized = Number(angle || 0) % 360
805
+
806
+ return normalized < 0 ? normalized + 360 : normalized
807
+ }
808
+ }