altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
@@ -0,0 +1,293 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic textual and byte records for generated Altium libraries.
7
+ */
8
+ export class AltiumLibraryRecordBuilder {
9
+ /**
10
+ * Builds a schematic component record.
11
+ * @param {object} bundle Normalized component bundle.
12
+ * @returns {string}
13
+ */
14
+ static buildSchematicComponentRecord(bundle) {
15
+ return AltiumLibraryRecordBuilder.#pipeRecord({
16
+ RECORD: 'Component',
17
+ Name: bundle.symbol.name,
18
+ SourceId: bundle.id,
19
+ DisplayName: bundle.name,
20
+ PinCount: String(bundle.symbol.pins.length),
21
+ PrimitiveCount: String(bundle.symbol.primitives.length)
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Builds a PCB footprint record.
27
+ * @param {object} bundle Normalized component bundle.
28
+ * @returns {string}
29
+ */
30
+ static buildPcbFootprintRecord(bundle) {
31
+ return AltiumLibraryRecordBuilder.#pipeRecord({
32
+ RECORD: 'Footprint',
33
+ Name: bundle.footprint.name,
34
+ SourceId: bundle.id,
35
+ DisplayName: bundle.name,
36
+ PadCount: String(bundle.footprint.pads.length),
37
+ TrackCount: String(bundle.footprint.tracks.length),
38
+ ModelCount: String(bundle.models.length)
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Builds a PcbLib Library/Data stream.
44
+ * @param {object[]} bundles Normalized component bundles.
45
+ * @returns {Uint8Array}
46
+ */
47
+ static buildPcbLibraryData(bundles) {
48
+ const countBytes = AltiumLibraryRecordBuilder.createCountHeader(
49
+ bundles.length
50
+ )
51
+
52
+ return AltiumLibraryRecordBuilder.concatBytes([
53
+ AltiumLibraryRecordBuilder.createProperties({
54
+ HEADER: 'PCB 6.0 Binary Library File',
55
+ WEIGHT: '0',
56
+ GENERATEDBY: 'ECAD Forge'
57
+ }),
58
+ countBytes,
59
+ ...bundles.map((bundle) =>
60
+ AltiumLibraryRecordBuilder.createStringBlock(
61
+ bundle.footprint.name
62
+ )
63
+ )
64
+ ])
65
+ }
66
+
67
+ /**
68
+ * Builds a PcbLib component parameters table.
69
+ * @param {object[]} bundles Normalized component bundles.
70
+ * @returns {Uint8Array}
71
+ */
72
+ static buildComponentParamsToc(bundles) {
73
+ return AltiumLibraryRecordBuilder.concatBytes(
74
+ bundles.map((bundle) =>
75
+ AltiumLibraryRecordBuilder.createLengthPrefixedAscii(
76
+ AltiumLibraryRecordBuilder.#pipeRecord({
77
+ Name: bundle.footprint.name,
78
+ 'Pad Count': String(bundle.footprint.pads.length),
79
+ Height: String(bundle.metadata.height || ''),
80
+ Description: String(bundle.metadata.description || '')
81
+ }) + '\r\n\u0000'
82
+ )
83
+ )
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Builds a SectionKeys stream.
89
+ * @param {object[]} bundles Normalized component bundles.
90
+ * @returns {Uint8Array}
91
+ */
92
+ static buildSectionKeys(bundles) {
93
+ const entries = bundles.map((bundle) => ({
94
+ fullName: bundle.footprint.name,
95
+ storageName: AltiumLibraryRecordBuilder.sanitizeStorageName(
96
+ bundle.footprint.name
97
+ )
98
+ }))
99
+
100
+ return AltiumLibraryRecordBuilder.concatBytes([
101
+ AltiumLibraryRecordBuilder.createCountHeader(entries.length),
102
+ ...entries.flatMap((entry) => [
103
+ AltiumLibraryRecordBuilder.createStringBlock(entry.fullName),
104
+ AltiumLibraryRecordBuilder.createStringBlock(entry.storageName)
105
+ ])
106
+ ])
107
+ }
108
+
109
+ /**
110
+ * Builds a footprint Data stream.
111
+ * @param {object} bundle Normalized component bundle.
112
+ * @returns {Uint8Array}
113
+ */
114
+ static buildFootprintData(bundle) {
115
+ return AltiumLibraryRecordBuilder.concatBytes([
116
+ AltiumLibraryRecordBuilder.createStringBlock(bundle.footprint.name)
117
+ ])
118
+ }
119
+
120
+ /**
121
+ * Builds a footprint Parameters stream.
122
+ * @param {object} bundle Normalized component bundle.
123
+ * @returns {Uint8Array}
124
+ */
125
+ static buildFootprintParameters(bundle) {
126
+ return AltiumLibraryRecordBuilder.createProperties({
127
+ PATTERN: bundle.footprint.name,
128
+ DESCRIPTION: String(bundle.metadata.description || ''),
129
+ HEIGHT: String(bundle.metadata.height || ''),
130
+ ITEMGUID: AltiumLibraryRecordBuilder.#guidFromText(bundle.id)
131
+ })
132
+ }
133
+
134
+ /**
135
+ * Builds model metadata entries.
136
+ * @param {{ model: object, id: string, checksum: number }[]} models Model rows.
137
+ * @returns {Uint8Array}
138
+ */
139
+ static buildModelsData(models) {
140
+ return AltiumLibraryRecordBuilder.concatBytes(
141
+ models.map((row) =>
142
+ AltiumLibraryRecordBuilder.createLengthPrefixedAscii(
143
+ AltiumLibraryRecordBuilder.#pipeRecord({
144
+ ID: row.id,
145
+ NAME: row.model.name,
146
+ CHECKSUM: String(row.checksum),
147
+ FORMAT: row.model.format
148
+ }) + '\u0000'
149
+ )
150
+ )
151
+ )
152
+ }
153
+
154
+ /**
155
+ * Creates a little-endian count header.
156
+ * @param {number} count Count value.
157
+ * @returns {Uint8Array}
158
+ */
159
+ static createCountHeader(count) {
160
+ const bytes = new Uint8Array(4)
161
+ new DataView(bytes.buffer).setUint32(0, Number(count || 0), true)
162
+ return bytes
163
+ }
164
+
165
+ /**
166
+ * Creates a PcbLib property stream.
167
+ * @param {Record<string, string>} properties Properties.
168
+ * @returns {Uint8Array}
169
+ */
170
+ static createProperties(properties) {
171
+ return AltiumLibraryRecordBuilder.createLengthPrefixedAscii(
172
+ AltiumLibraryRecordBuilder.#pipeRecord(properties) + '\u0000'
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Creates a Pascal-style string block.
178
+ * @param {string} text Text value.
179
+ * @returns {Uint8Array}
180
+ */
181
+ static createStringBlock(text) {
182
+ const encoded = new TextEncoder().encode(String(text || ''))
183
+ const bytes = new Uint8Array(4 + 1 + encoded.byteLength)
184
+ const view = new DataView(bytes.buffer)
185
+
186
+ view.setUint32(0, 1 + encoded.byteLength, true)
187
+ bytes[4] = encoded.byteLength
188
+ bytes.set(encoded, 5)
189
+
190
+ return bytes
191
+ }
192
+
193
+ /**
194
+ * Creates a length-prefixed ASCII/UTF-8 byte block.
195
+ * @param {string} text Text body.
196
+ * @returns {Uint8Array}
197
+ */
198
+ static createLengthPrefixedAscii(text) {
199
+ const encoded = new TextEncoder().encode(String(text || ''))
200
+ const bytes = new Uint8Array(4 + encoded.byteLength)
201
+
202
+ new DataView(bytes.buffer).setUint32(0, encoded.byteLength, true)
203
+ bytes.set(encoded, 4)
204
+
205
+ return bytes
206
+ }
207
+
208
+ /**
209
+ * Concatenates byte chunks.
210
+ * @param {Uint8Array[]} chunks Byte chunks.
211
+ * @returns {Uint8Array}
212
+ */
213
+ static concatBytes(chunks) {
214
+ const byteLength = chunks.reduce(
215
+ (sum, chunk) => sum + chunk.byteLength,
216
+ 0
217
+ )
218
+ const bytes = new Uint8Array(byteLength)
219
+ let offset = 0
220
+
221
+ for (const chunk of chunks) {
222
+ bytes.set(chunk, offset)
223
+ offset += chunk.byteLength
224
+ }
225
+
226
+ return bytes
227
+ }
228
+
229
+ /**
230
+ * Sanitizes one OLE storage name.
231
+ * @param {string} name Storage name.
232
+ * @returns {string}
233
+ */
234
+ static sanitizeStorageName(name) {
235
+ return String(name || 'Component')
236
+ .replace(/[\\/:\u0000-\u001f]/gu, '_')
237
+ .slice(0, 31)
238
+ }
239
+
240
+ /**
241
+ * Computes a simple deterministic checksum for generated model metadata.
242
+ * @param {Uint8Array} bytes Model bytes.
243
+ * @returns {number}
244
+ */
245
+ static checksumBytes(bytes) {
246
+ return [...bytes].reduce(
247
+ (checksum, value) => (checksum + value) >>> 0,
248
+ 0
249
+ )
250
+ }
251
+
252
+ /**
253
+ * Builds one pipe-delimited record.
254
+ * @param {Record<string, string>} fields Record fields.
255
+ * @returns {string}
256
+ */
257
+ static #pipeRecord(fields) {
258
+ return (
259
+ '|' +
260
+ Object.entries(fields)
261
+ .filter(([, value]) => String(value ?? '') !== '')
262
+ .map(([key, value]) => key + '=' + String(value))
263
+ .join('|')
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Builds a deterministic GUID-like id from text.
269
+ * @param {string} text Source text.
270
+ * @returns {string}
271
+ */
272
+ static #guidFromText(text) {
273
+ const hex = [...new TextEncoder().encode(String(text || ''))]
274
+ .map((value) => value.toString(16).padStart(2, '0'))
275
+ .join('')
276
+ .padEnd(32, '0')
277
+ .slice(0, 32)
278
+
279
+ return (
280
+ '{' +
281
+ hex.slice(0, 8) +
282
+ '-' +
283
+ hex.slice(8, 12) +
284
+ '-' +
285
+ hex.slice(12, 16) +
286
+ '-' +
287
+ hex.slice(16, 20) +
288
+ '-' +
289
+ hex.slice(20) +
290
+ '}'
291
+ )
292
+ }
293
+ }
@@ -59,6 +59,7 @@ const {
59
59
  extractSchematicFonts,
60
60
  extractSchematicFontDiagnostics,
61
61
  extractSchematicMetadata,
62
+ extractSchematicOwnerMetadata,
62
63
  extractSchematicTitleBlock,
63
64
  normalizeSchematicTextRecord
64
65
  } = SchematicTextParser
@@ -186,7 +187,7 @@ export class AltiumParser {
186
187
  AltiumParser.#collectOwnersWithImplicitDisplayMode(records)
187
188
  const activeMultipartOwnerParts =
188
189
  SchematicMultipartOwnerMatcher.collectActiveMultipartOwnerParts(
189
- records,
190
+ recordIndexAwareRecords,
190
191
  componentRecords
191
192
  )
192
193
  const sheetRecord = records.find(
@@ -237,8 +238,11 @@ export class AltiumParser {
237
238
  const rectangleRecords = drawableRecords.filter(
238
239
  (record) =>
239
240
  SchematicPrimitiveParser.isRectangleRecord(record.fields) &&
240
- AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
241
- AltiumParser.#hasCoordinatePair(record.fields, 'Corner')
241
+ ((AltiumParser.#hasCoordinatePair(record.fields, 'Location') &&
242
+ AltiumParser.#hasCoordinatePair(record.fields, 'Corner')) ||
243
+ SchematicPrimitiveParser.isPointListedRectangleRecord(
244
+ record.fields
245
+ ))
242
246
  )
243
247
  const roundedRectangleRecords = drawableRecords.filter(
244
248
  (record) =>
@@ -329,21 +333,30 @@ export class AltiumParser {
329
333
  }
330
334
 
331
335
  const metadataTexts = extractSchematicMetadata(textRecords)
336
+ const ownerMetadataTexts = extractSchematicOwnerMetadata(textRecords)
332
337
  const schematicFonts = extractSchematicFonts(sheetRecord?.fields)
333
338
  const schematicRenderDiagnostics = extractSchematicFontDiagnostics(
334
339
  sheetRecord?.fields
335
340
  )
336
- const sheetWidth =
341
+ const storedSheetWidth =
337
342
  parseNumericField(sheetRecord?.fields, 'CustomX') || 1500
338
- const sheetHeight =
343
+ const storedSheetHeight =
339
344
  parseNumericField(sheetRecord?.fields, 'CustomY') || 950
345
+ const templatePageSize =
346
+ AltiumLayoutParser.resolveSchematicTemplatePageSize(
347
+ sheetRecord?.fields,
348
+ storedSheetWidth,
349
+ storedSheetHeight
350
+ )
351
+ const sheetWidth = templatePageSize?.width || storedSheetWidth
352
+ const sheetHeight = templatePageSize?.height || storedSheetHeight
340
353
  const sheetMargin =
341
354
  parseNumericField(sheetRecord?.fields, 'CustomMarginWidth') || 20
342
355
  const sheet = {
343
356
  width: sheetWidth,
344
357
  height: sheetHeight,
345
- sourceWidth: sheetWidth,
346
- sourceHeight: sheetHeight,
358
+ sourceWidth: templatePageSize?.sourceWidth || storedSheetWidth,
359
+ sourceHeight: templatePageSize?.sourceHeight || storedSheetHeight,
347
360
  visibleGrid:
348
361
  parseNumericField(sheetRecord?.fields, 'VisibleGridSize') || 10,
349
362
  snapGrid:
@@ -363,6 +376,9 @@ export class AltiumParser {
363
376
  fonts: schematicFonts,
364
377
  sheetStyle:
365
378
  parseNumericField(sheetRecord?.fields, 'SheetStyle') || 0,
379
+ ...(templatePageSize?.paperSize
380
+ ? { paperSize: templatePageSize.paperSize }
381
+ : {}),
366
382
  titleBlock: extractSchematicTitleBlock(
367
383
  textRecords,
368
384
  metadataTexts,
@@ -415,7 +431,19 @@ export class AltiumParser {
415
431
  )
416
432
  )
417
433
  ]
418
- const pins = parseSchematicPins(pinRecords)
434
+ const ownerDrawnInternalPinOwners =
435
+ AltiumParser.#collectInaccessibleOwnerDrawnPrimitiveOwners(
436
+ drawableRecords
437
+ )
438
+ const numericEndpointLabelOwners =
439
+ AltiumParser.#collectNumericEndpointLabelOwners(
440
+ componentRecords,
441
+ recordIndexAwareRecords
442
+ )
443
+ const pins = parseSchematicPins(pinRecords, {
444
+ ownerDrawnInternalPinOwners,
445
+ numericEndpointLabelOwners
446
+ })
419
447
  const junctions = SchematicJunctionParser.parseSchematicJunctions(
420
448
  recordIndexAwareRecords
421
449
  )
@@ -489,7 +517,8 @@ export class AltiumParser {
489
517
  record.fields,
490
518
  metadataTexts,
491
519
  sheet,
492
- schematicFonts
520
+ schematicFonts,
521
+ ownerMetadataTexts
493
522
  )
494
523
  )
495
524
  .filter(Boolean)
@@ -524,7 +553,13 @@ export class AltiumParser {
524
553
  normalizedLines,
525
554
  pins,
526
555
  ports,
527
- rectangles
556
+ {
557
+ rectangles,
558
+ roundedRectangles,
559
+ ellipses,
560
+ arcs,
561
+ pies
562
+ }
528
563
  ),
529
564
  normalizedLines,
530
565
  pins,
@@ -584,6 +619,8 @@ export class AltiumParser {
584
619
 
585
620
  resolvedSheet.xZones =
586
621
  SchematicSheetStyleResolver.resolveXZones(resolvedSheet)
622
+ resolvedSheet.yZones =
623
+ SchematicSheetStyleResolver.resolveYZones(resolvedSheet)
587
624
  delete resolvedSheet.sheetStyle
588
625
 
589
626
  const title =
@@ -649,6 +686,11 @@ export class AltiumParser {
649
686
  busEntries,
650
687
  sheetEntries
651
688
  })
689
+ AltiumParser.#suppressRedundantSinglePinPowerNetNames(
690
+ pins,
691
+ nets,
692
+ anchoredTexts
693
+ )
652
694
  diagnostics.push(...netDiagnostics)
653
695
  const qa = SchematicQaReportBuilder.build({
654
696
  records: recordIndexAwareRecords,
@@ -741,6 +783,142 @@ export class AltiumParser {
741
783
  })
742
784
  }
743
785
 
786
+ /**
787
+ * Hides one-pin owner pin names when a connected power port already labels
788
+ * the same net, preserving the numeric pin endpoint label.
789
+ * @param {{ ownerIndex?: string, name?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
790
+ * @param {{ powerPorts?: { text?: string }[], pins?: { ownerIndex?: string, name?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[] }[]} nets
791
+ * @param {{ x: number, y: number, text?: string, recordType?: string }[]} texts
792
+ * @returns {void}
793
+ */
794
+ static #suppressRedundantSinglePinPowerNetNames(pins, nets, texts = []) {
795
+ const ownerPinCounts = new Map()
796
+
797
+ for (const pin of pins || []) {
798
+ const ownerIndex = String(pin.ownerIndex || '').trim()
799
+
800
+ if (!ownerIndex) continue
801
+
802
+ ownerPinCounts.set(
803
+ ownerIndex,
804
+ (ownerPinCounts.get(ownerIndex) || 0) + 1
805
+ )
806
+ }
807
+
808
+ for (const net of nets || []) {
809
+ const powerPortNames = new Set(
810
+ (net.powerPorts || [])
811
+ .map((powerPort) =>
812
+ AltiumParser.#normalizePowerNetLabel(powerPort.text)
813
+ )
814
+ .filter(Boolean)
815
+ )
816
+
817
+ if (!powerPortNames.size) continue
818
+
819
+ for (const pin of net.pins || []) {
820
+ if (
821
+ AltiumParser.#canSuppressSinglePinName(
822
+ pin,
823
+ ownerPinCounts
824
+ ) &&
825
+ powerPortNames.has(
826
+ AltiumParser.#normalizePowerNetLabel(pin.name)
827
+ )
828
+ ) {
829
+ pin.labelMode = 'number-only'
830
+ }
831
+ }
832
+ }
833
+
834
+ const powerPorts = (texts || []).filter(
835
+ (text) => text?.recordType === '17'
836
+ )
837
+
838
+ for (const pin of pins || []) {
839
+ if (!AltiumParser.#canSuppressSinglePinName(pin, ownerPinCounts)) {
840
+ continue
841
+ }
842
+
843
+ const pinName = AltiumParser.#normalizePowerNetLabel(pin.name)
844
+ const connectionPoint = AltiumParser.#resolvePinConnectionPoint(pin)
845
+ const matchesDirectPowerPort = powerPorts.some(
846
+ (powerPort) =>
847
+ pinName ===
848
+ AltiumParser.#normalizePowerNetLabel(powerPort.text) &&
849
+ AltiumParser.#pointsAreNear(connectionPoint, powerPort, 2)
850
+ )
851
+
852
+ if (matchesDirectPowerPort) {
853
+ pin.labelMode = 'number-only'
854
+ }
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Returns true when a pin is the only pin on its owner and currently shows
860
+ * a name label.
861
+ * @param {{ ownerIndex?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }} pin
862
+ * @param {Map<string, number>} ownerPinCounts
863
+ * @returns {boolean}
864
+ */
865
+ static #canSuppressSinglePinName(pin, ownerPinCounts) {
866
+ const ownerIndex = String(pin?.ownerIndex || '').trim()
867
+ const labelMode = pin?.labelMode || 'name-and-number'
868
+
869
+ return (
870
+ ownerIndex &&
871
+ ownerPinCounts.get(ownerIndex) === 1 &&
872
+ labelMode !== 'hidden' &&
873
+ labelMode !== 'number-only'
874
+ )
875
+ }
876
+
877
+ /**
878
+ * Resolves the outer electrical endpoint for a normalized schematic pin.
879
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
880
+ * @returns {{ x: number, y: number }}
881
+ */
882
+ static #resolvePinConnectionPoint(pin) {
883
+ switch (pin.orientation) {
884
+ case 'right':
885
+ return { x: pin.x + pin.length, y: pin.y }
886
+ case 'top':
887
+ return { x: pin.x, y: pin.y + pin.length }
888
+ case 'bottom':
889
+ return { x: pin.x, y: pin.y - pin.length }
890
+ case 'left':
891
+ default:
892
+ return { x: pin.x - pin.length, y: pin.y }
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Returns true when two schematic points are close enough to represent the
898
+ * same recovered connection endpoint.
899
+ * @param {{ x: number, y: number }} left
900
+ * @param {{ x: number, y: number }} right
901
+ * @param {number} tolerance
902
+ * @returns {boolean}
903
+ */
904
+ static #pointsAreNear(left, right, tolerance) {
905
+ return (
906
+ Math.abs(Number(left.x) - Number(right.x)) <= tolerance &&
907
+ Math.abs(Number(left.y) - Number(right.y)) <= tolerance
908
+ )
909
+ }
910
+
911
+ /**
912
+ * Normalizes a power-net label for structural comparisons.
913
+ * @param {string | undefined} label
914
+ * @returns {string}
915
+ */
916
+ static #normalizePowerNetLabel(label) {
917
+ return String(label || '')
918
+ .trim()
919
+ .toUpperCase()
920
+ }
921
+
744
922
  /**
745
923
  * Finds a visible text string with a given logical name.
746
924
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -784,6 +962,63 @@ export class AltiumParser {
784
962
  return owners
785
963
  }
786
964
 
965
+ /**
966
+ * Collects symbol owners that draw an inaccessible body around compact
967
+ * internal pins.
968
+ * @param {{ fields: Record<string, string | string[]> }[]} records
969
+ * @returns {Set<string>}
970
+ */
971
+ static #collectInaccessibleOwnerDrawnPrimitiveOwners(records) {
972
+ const owners = new Set()
973
+
974
+ for (const record of records) {
975
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
976
+ const ownerPartId = getField(record.fields, 'OwnerPartId')
977
+
978
+ if (
979
+ ownerIndex &&
980
+ ownerPartId &&
981
+ ownerPartId !== '-1' &&
982
+ parseBoolean(record.fields.IsNotAccesible) &&
983
+ SchematicComponentOwnerTextResolver.isDisplayModeSelectablePrimitive(
984
+ record.fields
985
+ )
986
+ ) {
987
+ owners.add(ownerIndex)
988
+ }
989
+ }
990
+
991
+ return owners
992
+ }
993
+
994
+ /**
995
+ * Collects component owners whose passive two-pin numeric endpoints are
996
+ * intended to remain visible in the schematic body.
997
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} componentRecords Component records.
998
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Indexed schematic records.
999
+ * @returns {Set<string>}
1000
+ */
1001
+ static #collectNumericEndpointLabelOwners(componentRecords, records) {
1002
+ const owners = new Set()
1003
+
1004
+ for (const componentRecord of componentRecords) {
1005
+ if (
1006
+ parseNumericField(componentRecord.fields, 'ComponentKind') !== 4
1007
+ ) {
1008
+ continue
1009
+ }
1010
+
1011
+ for (const ownerIndex of SchematicComponentOwnerTextResolver.resolveOwnerIndexes(
1012
+ componentRecord,
1013
+ records
1014
+ )) {
1015
+ owners.add(ownerIndex)
1016
+ }
1017
+ }
1018
+
1019
+ return owners
1020
+ }
1021
+
787
1022
  /**
788
1023
  * Returns true when one schematic record belongs to the active symbol
789
1024
  * display mode for its owner.