altium-toolkit 1.0.8 → 1.0.10

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 (102) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,224 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ const DEFAULT_POSITION_MODE = 'altium-pick-place'
6
+ const COMPONENT_ORIGIN_MODE = 'component-origin'
7
+
8
+ /**
9
+ * Resolves PCB pick-and-place coordinates from component origins and owned pad
10
+ * anchors.
11
+ */
12
+ export class PcbPickPlacePositionResolver {
13
+ /**
14
+ * Builds the public PnP model with the default mode and alternatives.
15
+ * @param {{ componentIndex: number, designator: string, pattern: string, layer: string, rotation: number, x: number, y: number }[]} components
16
+ * @param {{ componentIndex: number, pads?: { x?: number, y?: number }[] }[]} componentPrimitiveGroups
17
+ * @param {{ sourceComponents?: { componentIndex: number, rotation?: number }[] }} [options] Resolver options.
18
+ * @returns {{ units: object, positionMode: string, entries: object[], modes: { componentOrigin: { units: object, positionMode: string, entries: object[] } } }}
19
+ */
20
+ static buildModel(components, componentPrimitiveGroups, options = {}) {
21
+ const units = {
22
+ coordinate: 'mil',
23
+ angle: 'deg'
24
+ }
25
+
26
+ return {
27
+ units,
28
+ positionMode: DEFAULT_POSITION_MODE,
29
+ entries: PcbPickPlacePositionResolver.buildEntries(
30
+ components,
31
+ componentPrimitiveGroups,
32
+ DEFAULT_POSITION_MODE,
33
+ options
34
+ ),
35
+ modes: {
36
+ componentOrigin: {
37
+ units,
38
+ positionMode: COMPONENT_ORIGIN_MODE,
39
+ entries: PcbPickPlacePositionResolver.buildEntries(
40
+ components,
41
+ componentPrimitiveGroups,
42
+ COMPONENT_ORIGIN_MODE,
43
+ options
44
+ )
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Builds PnP entries for one coordinate mode.
52
+ * @param {{ componentIndex: number, designator: string, pattern: string, layer: string, rotation: number, x: number, y: number }[]} components
53
+ * @param {{ componentIndex: number, pads?: { x?: number, y?: number }[] }[]} componentPrimitiveGroups
54
+ * @param {'altium-pick-place' | 'component-origin' | string} mode
55
+ * @param {{ sourceComponents?: { componentIndex: number, rotation?: number }[] }} [options] Resolver options.
56
+ * @returns {{ designator: string, pattern: string, layer: string, rotation: number, x: number, y: number, componentOriginX: number, componentOriginY: number, padAnchorCount: number, positionSource: string }[]}
57
+ */
58
+ static buildEntries(
59
+ components,
60
+ componentPrimitiveGroups,
61
+ mode,
62
+ options = {}
63
+ ) {
64
+ const groupsByIndex = PcbPickPlacePositionResolver.#buildGroupLookup(
65
+ componentPrimitiveGroups
66
+ )
67
+ const sourceComponentsByIndex =
68
+ PcbPickPlacePositionResolver.#buildGroupLookup(
69
+ options.sourceComponents || []
70
+ )
71
+ const normalizedMode =
72
+ PcbPickPlacePositionResolver.normalizePositionMode(mode)
73
+
74
+ return (components || []).map((component) =>
75
+ PcbPickPlacePositionResolver.#buildEntry(
76
+ component,
77
+ groupsByIndex.get(Number(component.componentIndex)),
78
+ normalizedMode,
79
+ sourceComponentsByIndex.get(Number(component.componentIndex))
80
+ )
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Normalizes one public PnP coordinate-mode token.
86
+ * @param {string | null | undefined} mode
87
+ * @returns {'altium-pick-place' | 'component-origin'}
88
+ */
89
+ static normalizePositionMode(mode) {
90
+ const normalized = String(mode || DEFAULT_POSITION_MODE)
91
+ .trim()
92
+ .toLowerCase()
93
+ .replace(/_/gu, '-')
94
+
95
+ if (
96
+ normalized === '' ||
97
+ normalized === 'altium' ||
98
+ normalized === 'altium-pick-place' ||
99
+ normalized === 'pick-place'
100
+ ) {
101
+ return DEFAULT_POSITION_MODE
102
+ }
103
+
104
+ if (
105
+ normalized === 'component-origin' ||
106
+ normalized === 'origin' ||
107
+ normalized === 'part-origin' ||
108
+ normalized === 'footprint-origin'
109
+ ) {
110
+ return COMPONENT_ORIGIN_MODE
111
+ }
112
+
113
+ return DEFAULT_POSITION_MODE
114
+ }
115
+
116
+ /**
117
+ * Builds one PnP entry.
118
+ * @param {{ designator: string, pattern: string, layer: string, rotation: number, x: number, y: number }} component
119
+ * @param {{ pads?: { x?: number, y?: number }[] } | undefined} group
120
+ * @param {'altium-pick-place' | 'component-origin'} mode
121
+ * @param {{ rotation?: number } | undefined} sourceComponent Source component row.
122
+ * @returns {{ designator: string, pattern: string, layer: string, rotation: number, x: number, y: number, componentOriginX: number, componentOriginY: number, padAnchorCount: number, positionSource: string }}
123
+ */
124
+ static #buildEntry(component, group, mode, sourceComponent) {
125
+ const componentOriginX = Number(component.x || 0)
126
+ const componentOriginY = Number(component.y || 0)
127
+ const rotation = Number.isFinite(Number(sourceComponent?.rotation))
128
+ ? Number(sourceComponent.rotation)
129
+ : Number(component.rotation || 0)
130
+ const padAnchors = PcbPickPlacePositionResolver.#padAnchors(group?.pads)
131
+ const padCenter =
132
+ mode === DEFAULT_POSITION_MODE
133
+ ? PcbPickPlacePositionResolver.#padAnchorBoundsCenter(
134
+ padAnchors
135
+ )
136
+ : null
137
+ const position = padCenter || {
138
+ x: componentOriginX,
139
+ y: componentOriginY,
140
+ source: 'component-origin'
141
+ }
142
+
143
+ return {
144
+ designator: component.designator || '',
145
+ pattern: component.pattern || '',
146
+ layer: component.layer || '',
147
+ rotation: PcbPickPlacePositionResolver.#roundCoordinate(rotation),
148
+ x: PcbPickPlacePositionResolver.#roundCoordinate(position.x),
149
+ y: PcbPickPlacePositionResolver.#roundCoordinate(position.y),
150
+ componentOriginX:
151
+ PcbPickPlacePositionResolver.#roundCoordinate(componentOriginX),
152
+ componentOriginY:
153
+ PcbPickPlacePositionResolver.#roundCoordinate(componentOriginY),
154
+ padAnchorCount: padAnchors.length,
155
+ positionSource:
156
+ mode === COMPONENT_ORIGIN_MODE
157
+ ? 'component-origin'
158
+ : position.source
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Builds a lookup of component primitive groups by native component index.
164
+ * @param {{ componentIndex: number }[]} componentPrimitiveGroups
165
+ * @returns {Map<number, object>}
166
+ */
167
+ static #buildGroupLookup(componentPrimitiveGroups) {
168
+ const groupsByIndex = new Map()
169
+
170
+ for (const group of componentPrimitiveGroups || []) {
171
+ const componentIndex = Number(group?.componentIndex)
172
+ if (Number.isInteger(componentIndex)) {
173
+ groupsByIndex.set(componentIndex, group)
174
+ }
175
+ }
176
+
177
+ return groupsByIndex
178
+ }
179
+
180
+ /**
181
+ * Returns pad anchor points with finite coordinates.
182
+ * @param {{ x?: number, y?: number }[] | undefined} pads
183
+ * @returns {{ x: number, y: number }[]}
184
+ */
185
+ static #padAnchors(pads) {
186
+ return (pads || [])
187
+ .map((pad) => ({
188
+ x: Number(pad?.x),
189
+ y: Number(pad?.y)
190
+ }))
191
+ .filter(
192
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
193
+ )
194
+ }
195
+
196
+ /**
197
+ * Resolves the center of the owned pad-anchor bounds.
198
+ * @param {{ x: number, y: number }[]} padAnchors
199
+ * @returns {{ x: number, y: number, source: string } | null}
200
+ */
201
+ static #padAnchorBoundsCenter(padAnchors) {
202
+ if (!padAnchors.length) {
203
+ return null
204
+ }
205
+
206
+ const xs = padAnchors.map((point) => point.x)
207
+ const ys = padAnchors.map((point) => point.y)
208
+
209
+ return {
210
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
211
+ y: (Math.min(...ys) + Math.max(...ys)) / 2,
212
+ source: 'pad-anchor-bounds'
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Rounds one PnP coordinate for deterministic JSON output.
218
+ * @param {number} value
219
+ * @returns {number}
220
+ */
221
+ static #roundCoordinate(value) {
222
+ return Number(Number(value || 0).toFixed(6))
223
+ }
224
+ }
@@ -2,6 +2,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
6
+
5
7
  /**
6
8
  * Decodes Altium PrimitiveParameters/Data sidecar records.
7
9
  */
@@ -153,8 +155,7 @@ export class PcbPrimitiveParameterParser {
153
155
  * @returns {Record<string, string>}
154
156
  */
155
157
  static #parseRecordFields(bytes) {
156
- const text = new TextDecoder()
157
- .decode(bytes)
158
+ const text = PrintableTextDecoder.decodeBytes(bytes)
158
159
  .replace(/\u0000/gu, '')
159
160
  .replace(/\r\n?/gu, '\n')
160
161
  .trim()
@@ -365,82 +365,149 @@ export class PcbRawRecordRegistry {
365
365
  static #sliceSubrecordListRecords(descriptor, headerBytes, dataBytes) {
366
366
  const count = PcbRawRecordRegistry.#readRecordCount(headerBytes)
367
367
  const bytes = PcbRawRecordRegistry.#toUint8Array(dataBytes)
368
- const records = []
369
- let offset = 0
368
+ const boundaries = PcbRawRecordRegistry.#findSubrecordListBoundaries(
369
+ bytes,
370
+ 0,
371
+ descriptor,
372
+ count
373
+ )
370
374
 
371
- for (let index = 0; index < count; index += 1) {
372
- const remainingCount = count - index - 1
373
- const record = PcbRawRecordRegistry.#readSubrecordListRecordAt(
375
+ if (!boundaries) {
376
+ return []
377
+ }
378
+
379
+ return boundaries.map((boundary, index) => {
380
+ const endOffset = boundaries[index + 1]?.offset ?? bytes.byteLength
381
+
382
+ return {
383
+ recordBytes: bytes.slice(boundary.offset, endOffset),
384
+ offset: boundary.offset,
385
+ byteLength: endOffset - boundary.offset,
386
+ payloadByteLength: null,
387
+ encoding: 'subrecord-list',
388
+ objectId: descriptor.typeId,
389
+ recordIndex: index
390
+ }
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Finds all subrecord-list record boundaries without recursive validation.
396
+ * @param {Uint8Array} bytes
397
+ * @param {number} offset
398
+ * @param {object} descriptor
399
+ * @param {number} count
400
+ * @returns {{ offset: number, minimumEnd: number }[] | null}
401
+ */
402
+ static #findSubrecordListBoundaries(bytes, offset, descriptor, count) {
403
+ if (!count) {
404
+ return []
405
+ }
406
+
407
+ const firstMinimumEnd =
408
+ PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
374
409
  bytes,
375
410
  offset,
376
- descriptor,
377
- remainingCount
411
+ descriptor
378
412
  )
379
413
 
380
- if (!record) {
381
- return []
414
+ if (!firstMinimumEnd) {
415
+ return null
416
+ }
417
+
418
+ const boundaries = [{ offset, minimumEnd: firstMinimumEnd }]
419
+ const alternativeScanOffsets = [null]
420
+ let depth = 1
421
+ let scanOffset = firstMinimumEnd
422
+
423
+ while (depth < count) {
424
+ const candidate =
425
+ PcbRawRecordRegistry.#findNextSubrecordListCandidate(
426
+ bytes,
427
+ scanOffset,
428
+ descriptor
429
+ )
430
+
431
+ if (!candidate) {
432
+ let foundAlternative = false
433
+
434
+ while (depth > 1 && !foundAlternative) {
435
+ depth -= 1
436
+ boundaries.length = depth
437
+
438
+ const alternativeOffset = alternativeScanOffsets[depth]
439
+ alternativeScanOffsets.length = depth
440
+
441
+ if (alternativeOffset !== null) {
442
+ scanOffset = alternativeOffset
443
+ foundAlternative = true
444
+ }
445
+ }
446
+
447
+ if (!foundAlternative) {
448
+ return null
449
+ }
450
+
451
+ continue
382
452
  }
383
453
 
384
- records.push({ ...record, recordIndex: index })
385
- offset += record.byteLength
454
+ boundaries[depth] = {
455
+ offset: candidate.offset,
456
+ minimumEnd: candidate.minimumEnd
457
+ }
458
+ alternativeScanOffsets[depth] = candidate.alternativeOffset
459
+ depth += 1
460
+ scanOffset = candidate.minimumEnd
386
461
  }
387
462
 
388
- return records
463
+ return boundaries
389
464
  }
390
465
 
391
466
  /**
392
- * Reads one subrecord-list primitive record at an offset.
467
+ * Finds the next plausible subrecord-list record boundary.
393
468
  * @param {Uint8Array} bytes
394
469
  * @param {number} offset
395
470
  * @param {object} descriptor
396
- * @param {number} remainingCount
397
- * @returns {object | null}
471
+ * @returns {{ offset: number, minimumEnd: number, alternativeOffset: number | null } | null}
398
472
  */
399
- static #readSubrecordListRecordAt(
400
- bytes,
401
- offset,
402
- descriptor,
403
- remainingCount
404
- ) {
405
- if (
406
- offset + 1 > bytes.byteLength ||
407
- bytes[offset] !== descriptor.typeId
408
- ) {
409
- return null
410
- }
473
+ static #findNextSubrecordListCandidate(bytes, offset, descriptor) {
474
+ let cursor = offset
411
475
 
412
- const minimumEnd = PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
413
- bytes,
414
- offset,
415
- descriptor
416
- )
476
+ while (cursor < bytes.byteLength) {
477
+ const minimumEnd =
478
+ PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
479
+ bytes,
480
+ cursor,
481
+ descriptor
482
+ )
483
+ const unknownSubrecord = PcbRawRecordRegistry.#readSubrecordAt(
484
+ bytes,
485
+ cursor
486
+ )
417
487
 
418
- if (!minimumEnd) {
419
- return null
420
- }
488
+ if (minimumEnd) {
489
+ const alternativeOffset =
490
+ unknownSubrecord?.nextOffset ?? cursor + 1
491
+
492
+ return {
493
+ offset: cursor,
494
+ minimumEnd,
495
+ alternativeOffset:
496
+ alternativeOffset < bytes.byteLength
497
+ ? alternativeOffset
498
+ : null
499
+ }
500
+ }
421
501
 
422
- const endOffset = remainingCount
423
- ? PcbRawRecordRegistry.#findNextSubrecordListRecordOffset(
424
- bytes,
425
- minimumEnd,
426
- descriptor,
427
- remainingCount
428
- )
429
- : bytes.byteLength
502
+ if (!unknownSubrecord) {
503
+ cursor += 1
504
+ continue
505
+ }
430
506
 
431
- if (!endOffset) {
432
- return null
507
+ cursor = unknownSubrecord.nextOffset
433
508
  }
434
509
 
435
- return {
436
- recordBytes: bytes.slice(offset, endOffset),
437
- offset,
438
- byteLength: endOffset - offset,
439
- payloadByteLength: null,
440
- encoding: 'subrecord-list',
441
- objectId: descriptor.typeId,
442
- recordIndex: 0
443
- }
510
+ return null
444
511
  }
445
512
 
446
513
  /**
@@ -484,82 +551,6 @@ export class PcbRawRecordRegistry {
484
551
  return cursor
485
552
  }
486
553
 
487
- /**
488
- * Finds the next known subrecord-list primitive boundary.
489
- * @param {Uint8Array} bytes
490
- * @param {number} offset
491
- * @param {object} descriptor
492
- * @param {number} remainingCount
493
- * @returns {number | null}
494
- */
495
- static #findNextSubrecordListRecordOffset(
496
- bytes,
497
- offset,
498
- descriptor,
499
- remainingCount
500
- ) {
501
- let cursor = offset
502
-
503
- while (cursor < bytes.byteLength) {
504
- if (
505
- PcbRawRecordRegistry.#canReadSubrecordListSequence(
506
- bytes,
507
- cursor,
508
- descriptor,
509
- remainingCount
510
- )
511
- ) {
512
- return cursor
513
- }
514
-
515
- const unknownSubrecord = PcbRawRecordRegistry.#readSubrecordAt(
516
- bytes,
517
- cursor
518
- )
519
- cursor = unknownSubrecord ? unknownSubrecord.nextOffset : cursor + 1
520
- }
521
-
522
- return null
523
- }
524
-
525
- /**
526
- * Checks whether the remaining subrecord-list records are readable.
527
- * @param {Uint8Array} bytes
528
- * @param {number} offset
529
- * @param {object} descriptor
530
- * @param {number} remainingCount
531
- * @returns {boolean}
532
- */
533
- static #canReadSubrecordListSequence(
534
- bytes,
535
- offset,
536
- descriptor,
537
- remainingCount
538
- ) {
539
- const minimumEnd = PcbRawRecordRegistry.#readMinimumSubrecordListEnd(
540
- bytes,
541
- offset,
542
- descriptor
543
- )
544
-
545
- if (!minimumEnd) {
546
- return false
547
- }
548
-
549
- if (remainingCount <= 1) {
550
- return true
551
- }
552
-
553
- return (
554
- PcbRawRecordRegistry.#findNextSubrecordListRecordOffset(
555
- bytes,
556
- minimumEnd,
557
- descriptor,
558
- remainingCount - 1
559
- ) !== null
560
- )
561
- }
562
-
563
554
  /**
564
555
  * Slices PCB text records including their variable string tails.
565
556
  * @param {object} descriptor
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
6
+ import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
6
7
 
7
8
  /**
8
9
  * Decodes Altium PCB region primitive streams.
@@ -164,17 +165,26 @@ export class PcbRegionPrimitiveParser {
164
165
  net: 3,
165
166
  polygon: 5
166
167
  })
168
+ const kind = PcbRegionPrimitiveParser.#numericKind(properties.KIND)
169
+ const legacyCutout =
170
+ PcbRegionPrimitiveParser.#legacyCutoutClassification(
171
+ properties,
172
+ kind
173
+ )
167
174
 
168
175
  return {
169
176
  region: {
170
177
  layerId,
171
178
  layerCode: layerId,
172
179
  ...ownershipIndexes,
173
- kind: Number(properties.KIND || 0),
180
+ kind,
181
+ ...legacyCutout.fields,
174
182
  isKeepout: flags2 === 2,
175
183
  isBoardCutout:
184
+ legacyCutout.isBoardCutout ||
176
185
  String(properties.ISBOARDCUTOUT || '').toUpperCase() ===
177
- 'TRUE',
186
+ 'TRUE',
187
+ ...legacyCutout.cutoutFlags,
178
188
  isShapeBased:
179
189
  shapeBased ||
180
190
  String(properties.ISSHAPEBASED || '').toUpperCase() ===
@@ -187,6 +197,66 @@ export class PcbRegionPrimitiveParser {
187
197
  }
188
198
  }
189
199
 
200
+ /**
201
+ * Parses a region kind while avoiding NaN for legacy symbolic labels.
202
+ * @param {string | undefined} rawKind Raw KIND value.
203
+ * @returns {number | null}
204
+ */
205
+ static #numericKind(rawKind) {
206
+ if (rawKind === undefined || rawKind === null || rawKind === '') {
207
+ return 0
208
+ }
209
+
210
+ const kind = Number(rawKind)
211
+ return Number.isFinite(kind) ? kind : null
212
+ }
213
+
214
+ /**
215
+ * Builds cutout fields from legacy string KIND labels.
216
+ * @param {Record<string, string>} properties Native property map.
217
+ * @param {number | null} numericKind Parsed numeric kind.
218
+ * @returns {{ isBoardCutout: boolean, fields: object, cutoutFlags: object }}
219
+ */
220
+ static #legacyCutoutClassification(properties, numericKind) {
221
+ const rawKind = String(properties.KIND || '').trim()
222
+ if (numericKind !== null || !rawKind) {
223
+ return {
224
+ isBoardCutout: false,
225
+ fields: {},
226
+ cutoutFlags: {}
227
+ }
228
+ }
229
+
230
+ const normalized = rawKind.replace(/[^a-z0-9]/giu, '').toLowerCase()
231
+ const isBoardCutout = normalized === 'boardcutout'
232
+ const isPolygonPourCutout =
233
+ normalized === 'polygonpourcutout' ||
234
+ normalized === 'polygoncutout' ||
235
+ normalized === 'pourcutout'
236
+ const classification =
237
+ isBoardCutout || isPolygonPourCutout
238
+ ? {
239
+ isBoardCutout,
240
+ isPolygonPourCutout,
241
+ source: 'legacy-kind',
242
+ rawKind
243
+ }
244
+ : null
245
+
246
+ return {
247
+ isBoardCutout,
248
+ fields: {
249
+ rawKind
250
+ },
251
+ cutoutFlags: classification
252
+ ? {
253
+ isPolygonPourCutout,
254
+ cutoutClassification: classification
255
+ }
256
+ : {}
257
+ }
258
+ }
259
+
190
260
  /**
191
261
  * Reads one simple double-coordinate region vertex list.
192
262
  * @param {DataView} view
@@ -254,7 +324,10 @@ export class PcbRegionPrimitiveParser {
254
324
  * @returns {Record<string, string>}
255
325
  */
256
326
  static #parsePropertyBytes(bytes) {
257
- const text = new TextDecoder().decode(bytes).replace(/\u0000+$/u, '')
327
+ const text = PrintableTextDecoder.decodeBytes(bytes).replace(
328
+ /\u0000+$/u,
329
+ ''
330
+ )
258
331
  const properties = {}
259
332
 
260
333
  for (const part of text.split('|')) {