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,564 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getField, parseBoolean, parseNumericField } = ParserUtils
8
+
9
+ /**
10
+ * Resolves which multipart symbol section is visible for one schematic owner.
11
+ */
12
+ export class SchematicMultipartOwnerMatcher {
13
+ /**
14
+ * Matches multipart owner indexes to the currently visible part id stored
15
+ * on their component placements.
16
+ * @param {{ raw: string, fields: Record<string, string | string[]> }[]} records
17
+ * @param {{ raw: string, fields: Record<string, string | string[]> }[]} componentRecords
18
+ * @returns {Map<string, string>}
19
+ */
20
+ static collectActiveMultipartOwnerParts(records, componentRecords) {
21
+ const partBounds = new Map()
22
+ const ownerBounds = new Map()
23
+ const directOwnerIndexesByRecord = new WeakMap()
24
+
25
+ for (const record of records) {
26
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
27
+ const ownerPartId = getField(record.fields, 'OwnerPartId')
28
+
29
+ if (!ownerIndex || !ownerPartId || ownerPartId === '-1') {
30
+ continue
31
+ }
32
+
33
+ const points =
34
+ SchematicMultipartOwnerMatcher.#collectSchematicRecordPoints(
35
+ record.fields
36
+ )
37
+ if (!points.length) {
38
+ continue
39
+ }
40
+
41
+ const key = ownerIndex + '::' + ownerPartId
42
+ const existingBounds = partBounds.get(key) || {
43
+ ownerIndex,
44
+ ownerPartId,
45
+ minX: Number.POSITIVE_INFINITY,
46
+ minY: Number.POSITIVE_INFINITY,
47
+ maxX: Number.NEGATIVE_INFINITY,
48
+ maxY: Number.NEGATIVE_INFINITY,
49
+ leftPinLength: 0,
50
+ rightPinLength: 0
51
+ }
52
+
53
+ SchematicMultipartOwnerMatcher.#expandBounds(existingBounds, points)
54
+
55
+ existingBounds.leftPinLength = Math.max(
56
+ existingBounds.leftPinLength,
57
+ SchematicMultipartOwnerMatcher.#collectLeftPinLength(
58
+ record.fields
59
+ )
60
+ )
61
+ existingBounds.rightPinLength = Math.max(
62
+ existingBounds.rightPinLength,
63
+ SchematicMultipartOwnerMatcher.#collectRightPinLength(
64
+ record.fields
65
+ )
66
+ )
67
+
68
+ partBounds.set(key, existingBounds)
69
+
70
+ const existingOwnerBounds = ownerBounds.get(ownerIndex) || {
71
+ ownerIndex,
72
+ minX: Number.POSITIVE_INFINITY,
73
+ minY: Number.POSITIVE_INFINITY,
74
+ maxX: Number.NEGATIVE_INFINITY,
75
+ maxY: Number.NEGATIVE_INFINITY
76
+ }
77
+
78
+ SchematicMultipartOwnerMatcher.#expandBounds(
79
+ existingOwnerBounds,
80
+ points
81
+ )
82
+ ownerBounds.set(ownerIndex, existingOwnerBounds)
83
+ }
84
+
85
+ for (let index = 0; index < records.length; index += 1) {
86
+ const record = records[index]
87
+ if (getField(record.fields, 'RECORD') !== '1') {
88
+ continue
89
+ }
90
+
91
+ const currentPartId = String(
92
+ parseNumericField(record.fields, 'CurrentPartId') || ''
93
+ )
94
+ const partCount = parseNumericField(record.fields, 'PartCount') || 0
95
+
96
+ if (!currentPartId || partCount <= 1) {
97
+ continue
98
+ }
99
+
100
+ const directOwnerIndex =
101
+ SchematicMultipartOwnerMatcher.#findSerializedOwnerIndex(
102
+ records,
103
+ index
104
+ )
105
+
106
+ if (!directOwnerIndex) {
107
+ continue
108
+ }
109
+
110
+ directOwnerIndexesByRecord.set(record, directOwnerIndex)
111
+ }
112
+
113
+ const activeOwnerParts = new Map()
114
+
115
+ for (const record of componentRecords) {
116
+ const currentPartId = String(
117
+ parseNumericField(record.fields, 'CurrentPartId') || ''
118
+ )
119
+ const partCount = parseNumericField(record.fields, 'PartCount') || 0
120
+ const x = parseNumericField(record.fields, 'Location.X')
121
+ const y = parseNumericField(record.fields, 'Location.Y')
122
+ const isMirrored = parseBoolean(record.fields.IsMirrored)
123
+
124
+ if (!currentPartId || partCount <= 1 || x === null || y === null) {
125
+ continue
126
+ }
127
+
128
+ const directOwnerIndex = directOwnerIndexesByRecord.get(record)
129
+
130
+ if (directOwnerIndex) {
131
+ activeOwnerParts.set(directOwnerIndex, currentPartId)
132
+ continue
133
+ }
134
+
135
+ const bestPartMatch =
136
+ SchematicMultipartOwnerMatcher.#findBestPartBoundsMatch(
137
+ partBounds,
138
+ currentPartId,
139
+ x,
140
+ y,
141
+ isMirrored
142
+ )
143
+
144
+ if (bestPartMatch && bestPartMatch.score <= 4) {
145
+ activeOwnerParts.set(
146
+ bestPartMatch.ownerIndex,
147
+ bestPartMatch.ownerPartId
148
+ )
149
+ continue
150
+ }
151
+
152
+ const bestOwnerMatch =
153
+ SchematicMultipartOwnerMatcher.#findBestOwnerBoundsMatch(
154
+ ownerBounds,
155
+ x,
156
+ y
157
+ )
158
+
159
+ if (bestOwnerMatch && bestOwnerMatch.score <= 4) {
160
+ activeOwnerParts.set(bestOwnerMatch.ownerIndex, currentPartId)
161
+ }
162
+ }
163
+
164
+ return activeOwnerParts
165
+ }
166
+
167
+ /**
168
+ * Resolves the dominant owner index serialized after one component record.
169
+ * This preserves multipart selection when library origins do not align with
170
+ * the current geometric anchor heuristics.
171
+ * @param {{ raw: string, fields: Record<string, string | string[]> }[]} records
172
+ * @param {number} componentIndex
173
+ * @returns {string}
174
+ */
175
+ static #findSerializedOwnerIndex(records, componentIndex) {
176
+ const ownerCounts = new Map()
177
+ const firstSeenOrder = new Map()
178
+
179
+ for (
180
+ let index = componentIndex + 1;
181
+ index < records.length;
182
+ index += 1
183
+ ) {
184
+ const record = records[index]
185
+ if (getField(record.fields, 'RECORD') === '1') {
186
+ break
187
+ }
188
+
189
+ if (
190
+ !SchematicMultipartOwnerMatcher.#isSerializedOwnerCandidate(
191
+ record.fields
192
+ )
193
+ ) {
194
+ continue
195
+ }
196
+
197
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
198
+
199
+ if (!firstSeenOrder.has(ownerIndex)) {
200
+ firstSeenOrder.set(ownerIndex, firstSeenOrder.size)
201
+ }
202
+
203
+ ownerCounts.set(ownerIndex, (ownerCounts.get(ownerIndex) || 0) + 1)
204
+ }
205
+
206
+ const bestOwner = [...ownerCounts.entries()].sort((left, right) => {
207
+ if (left[1] !== right[1]) {
208
+ return right[1] - left[1]
209
+ }
210
+
211
+ return firstSeenOrder.get(left[0]) - firstSeenOrder.get(right[0])
212
+ })[0]
213
+
214
+ if (!bestOwner) {
215
+ return ''
216
+ }
217
+
218
+ const secondBestCount =
219
+ [...ownerCounts.values()].sort((left, right) => right - left)[1] ||
220
+ 0
221
+ const [ownerIndex, bestCount] = bestOwner
222
+
223
+ if (
224
+ bestCount < 3 ||
225
+ (secondBestCount > 0 && bestCount < secondBestCount * 3)
226
+ ) {
227
+ return ''
228
+ }
229
+
230
+ return ownerIndex
231
+ }
232
+
233
+ /**
234
+ * Returns true when one serialized record contributes to the dominant
235
+ * owner block for a placed component.
236
+ * @param {Record<string, string | string[]>} fields
237
+ * @returns {boolean}
238
+ */
239
+ static #isSerializedOwnerCandidate(fields) {
240
+ const ownerIndex = getField(fields, 'OwnerIndex')
241
+ const recordType = getField(fields, 'RECORD')
242
+
243
+ if (!ownerIndex) {
244
+ return false
245
+ }
246
+
247
+ if (['45', '46', '48'].includes(recordType)) {
248
+ return false
249
+ }
250
+
251
+ return !(
252
+ recordType === '41' && getField(fields, 'Name') === 'PinUniqueId'
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Returns true when one schematic record belongs to the selected visible
258
+ * part for a multipart owner.
259
+ * @param {Record<string, string | string[]>} fields
260
+ * @param {Map<string, string>} activeMultipartOwnerParts
261
+ * @returns {boolean}
262
+ */
263
+ static isActiveOwnerPartRecord(fields, activeMultipartOwnerParts) {
264
+ const ownerIndex = getField(fields, 'OwnerIndex')
265
+ if (!ownerIndex) {
266
+ return true
267
+ }
268
+
269
+ const activePartId = activeMultipartOwnerParts.get(ownerIndex)
270
+ if (!activePartId) {
271
+ return true
272
+ }
273
+
274
+ const ownerPartId = getField(fields, 'OwnerPartId')
275
+ if (!ownerPartId || ownerPartId === '-1') {
276
+ return true
277
+ }
278
+
279
+ return ownerPartId === activePartId
280
+ }
281
+
282
+ /**
283
+ * Collects the coordinate points embedded in one schematic record.
284
+ * @param {Record<string, string | string[]>} fields
285
+ * @returns {[number, number][]}
286
+ */
287
+ static #collectSchematicRecordPoints(fields) {
288
+ const points = []
289
+ const locationX = parseNumericField(fields, 'Location.X')
290
+ const locationY = parseNumericField(fields, 'Location.Y')
291
+ const cornerX = parseNumericField(fields, 'Corner.X')
292
+ const cornerY = parseNumericField(fields, 'Corner.Y')
293
+ const locationCount = parseNumericField(fields, 'LocationCount') || 0
294
+
295
+ if (locationX !== null && locationY !== null) {
296
+ points.push([locationX, locationY])
297
+ }
298
+
299
+ if (cornerX !== null && cornerY !== null) {
300
+ points.push([cornerX, cornerY])
301
+ }
302
+
303
+ for (let index = 1; index <= locationCount; index += 1) {
304
+ const x = parseNumericField(fields, 'X' + index)
305
+ const y = parseNumericField(fields, 'Y' + index)
306
+
307
+ if (x === null || y === null) {
308
+ break
309
+ }
310
+
311
+ points.push([x, y])
312
+ }
313
+
314
+ return points
315
+ }
316
+
317
+ /**
318
+ * Expands one accumulated bounds box to include a list of points.
319
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
320
+ * @param {[number, number][]} points
321
+ * @returns {void}
322
+ */
323
+ static #expandBounds(bounds, points) {
324
+ for (const [x, y] of points) {
325
+ bounds.minX = Math.min(bounds.minX, x)
326
+ bounds.minY = Math.min(bounds.minY, y)
327
+ bounds.maxX = Math.max(bounds.maxX, x)
328
+ bounds.maxY = Math.max(bounds.maxY, y)
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Finds the closest part-specific multipart bounds match for one component
334
+ * placement using the existing per-part anchor heuristics.
335
+ * @param {Map<string, { ownerIndex: string, ownerPartId: string, minX: number, minY: number, maxX: number, maxY: number, leftPinLength: number, rightPinLength: number }>} partBounds
336
+ * @param {string} currentPartId
337
+ * @param {number} x
338
+ * @param {number} y
339
+ * @param {boolean} isMirrored
340
+ * @returns {{ ownerIndex: string, ownerPartId: string, minX: number, minY: number, maxX: number, maxY: number, leftPinLength: number, rightPinLength: number, score: number } | undefined}
341
+ */
342
+ static #findBestPartBoundsMatch(
343
+ partBounds,
344
+ currentPartId,
345
+ x,
346
+ y,
347
+ isMirrored
348
+ ) {
349
+ return [...partBounds.values()]
350
+ .filter((bounds) => bounds.ownerPartId === currentPartId)
351
+ .map((bounds) => ({
352
+ ...bounds,
353
+ score: SchematicMultipartOwnerMatcher.#scoreBoundsAnchor(
354
+ bounds,
355
+ x,
356
+ y,
357
+ isMirrored,
358
+ currentPartId
359
+ )
360
+ }))
361
+ .sort((left, right) => left.score - right.score)[0]
362
+ }
363
+
364
+ /**
365
+ * Finds the closest owner-level multipart bounds match for one component
366
+ * placement when the part-specific corner anchors do not line up.
367
+ * @param {Map<string, { ownerIndex: string, minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds
368
+ * @param {number} x
369
+ * @param {number} y
370
+ * @returns {{ ownerIndex: string, minX: number, minY: number, maxX: number, maxY: number, score: number, centerScore: number, area: number } | undefined}
371
+ */
372
+ static #findBestOwnerBoundsMatch(ownerBounds, x, y) {
373
+ return [...ownerBounds.values()]
374
+ .map((bounds) => ({
375
+ ...bounds,
376
+ score: SchematicMultipartOwnerMatcher.#scoreOwnerBoundsMatch(
377
+ bounds,
378
+ x,
379
+ y
380
+ ),
381
+ centerScore:
382
+ SchematicMultipartOwnerMatcher.#scoreOwnerBoundsCenter(
383
+ bounds,
384
+ x,
385
+ y
386
+ ),
387
+ area: (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)
388
+ }))
389
+ .sort((left, right) => {
390
+ if (left.score !== right.score) {
391
+ return left.score - right.score
392
+ }
393
+ if (left.centerScore !== right.centerScore) {
394
+ return left.centerScore - right.centerScore
395
+ }
396
+ return left.area - right.area
397
+ })[0]
398
+ }
399
+
400
+ /**
401
+ * Scores how far one component placement sits outside an owner's overall
402
+ * multipart bounds. Points inside the box score zero.
403
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
404
+ * @param {number} x
405
+ * @param {number} y
406
+ * @returns {number}
407
+ */
408
+ static #scoreOwnerBoundsMatch(bounds, x, y) {
409
+ const distanceX =
410
+ x < bounds.minX ? bounds.minX - x : Math.max(0, x - bounds.maxX)
411
+ const distanceY =
412
+ y < bounds.minY ? bounds.minY - y : Math.max(0, y - bounds.maxY)
413
+
414
+ return distanceX + distanceY
415
+ }
416
+
417
+ /**
418
+ * Scores how close one component placement is to the center of an owner's
419
+ * overall bounds so overlapping matches prefer the most local owner.
420
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
421
+ * @param {number} x
422
+ * @param {number} y
423
+ * @returns {number}
424
+ */
425
+ static #scoreOwnerBoundsCenter(bounds, x, y) {
426
+ const centerX = (bounds.minX + bounds.maxX) / 2
427
+ const centerY = (bounds.minY + bounds.maxY) / 2
428
+
429
+ return Math.abs(centerX - x) + Math.abs(centerY - y)
430
+ }
431
+
432
+ /**
433
+ * Scores how closely one component placement matches the corners of one
434
+ * multipart part bounds box. Altium mirrored units can anchor on the
435
+ * right-hand side instead of the default top-left corner.
436
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
437
+ * @param {number} x
438
+ * @param {number} y
439
+ * @param {boolean} isMirrored
440
+ * @param {string} currentPartId
441
+ * @returns {number}
442
+ */
443
+ static #scoreBoundsAnchor(bounds, x, y, isMirrored, currentPartId) {
444
+ const midpointY = (bounds.minY + bounds.maxY) / 2
445
+ const scores = []
446
+
447
+ scores.push(Math.abs(bounds.minX - x) + Math.abs(bounds.minY - y))
448
+
449
+ if (
450
+ SchematicMultipartOwnerMatcher.#isCompactHorizontalMultipart(
451
+ bounds
452
+ ) &&
453
+ bounds.leftPinLength > 0
454
+ ) {
455
+ scores.push(
456
+ Math.abs(bounds.minX - bounds.leftPinLength - x) +
457
+ Math.abs(midpointY - y)
458
+ )
459
+ }
460
+
461
+ if (isMirrored) {
462
+ scores.push(
463
+ Math.abs(bounds.maxX - x) + Math.abs(bounds.minY - y),
464
+ Math.abs(bounds.maxX - x) + Math.abs(bounds.maxY - y)
465
+ )
466
+
467
+ if (bounds.rightPinLength > 0) {
468
+ scores.push(
469
+ Math.abs(bounds.maxX + bounds.rightPinLength - x) +
470
+ Math.abs(midpointY - y)
471
+ )
472
+ }
473
+ }
474
+
475
+ return Math.min(...scores)
476
+ }
477
+
478
+ /**
479
+ * Collects the left pin length for one raw schematic pin record.
480
+ * @param {Record<string, string | string[]>} fields
481
+ * @returns {number}
482
+ */
483
+ static #collectLeftPinLength(fields) {
484
+ if (getField(fields, 'RECORD') !== '2') {
485
+ return 0
486
+ }
487
+
488
+ const pinLength = parseNumericField(fields, 'PinLength')
489
+ const orientation =
490
+ SchematicMultipartOwnerMatcher.#inferSchematicPinOrientation(
491
+ parseNumericField(fields, 'PinConglomerate')
492
+ )
493
+
494
+ if (pinLength === null || pinLength <= 0 || orientation !== 'left') {
495
+ return 0
496
+ }
497
+
498
+ return pinLength
499
+ }
500
+
501
+ /**
502
+ * Collects the right pin length for one raw schematic pin record.
503
+ * @param {Record<string, string | string[]>} fields
504
+ * @returns {number}
505
+ */
506
+ static #collectRightPinLength(fields) {
507
+ if (getField(fields, 'RECORD') !== '2') {
508
+ return 0
509
+ }
510
+
511
+ const pinLength = parseNumericField(fields, 'PinLength')
512
+ const orientation =
513
+ SchematicMultipartOwnerMatcher.#inferSchematicPinOrientation(
514
+ parseNumericField(fields, 'PinConglomerate')
515
+ )
516
+
517
+ if (pinLength === null || pinLength <= 0 || orientation !== 'right') {
518
+ return 0
519
+ }
520
+
521
+ return pinLength
522
+ }
523
+
524
+ /**
525
+ * Returns true when one owner bounds box looks like a compact horizontal
526
+ * passive multipart unit anchored from its left pin endpoint.
527
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
528
+ * @returns {boolean}
529
+ */
530
+ static #isCompactHorizontalMultipart(bounds) {
531
+ const width = bounds.maxX - bounds.minX
532
+ const height = bounds.maxY - bounds.minY
533
+
534
+ return width <= 30 && height <= 20 && width > height
535
+ }
536
+
537
+ /**
538
+ * Maps raw pin conglomerates into schematic pin orientations.
539
+ * @param {number | null} conglomerate
540
+ * @returns {'left' | 'right' | 'top' | 'bottom' | null}
541
+ */
542
+ static #inferSchematicPinOrientation(conglomerate) {
543
+ switch (conglomerate) {
544
+ case 34:
545
+ case 50:
546
+ case 58:
547
+ return 'left'
548
+ case 32:
549
+ case 48:
550
+ case 56:
551
+ return 'right'
552
+ case 35:
553
+ case 51:
554
+ case 59:
555
+ return 'top'
556
+ case 33:
557
+ case 49:
558
+ case 57:
559
+ return 'bottom'
560
+ default:
561
+ return null
562
+ }
563
+ }
564
+ }