altium-toolkit 1.0.9 → 1.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 (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -81,6 +81,10 @@ export class PcbPadStackParser {
81
81
  const flags = PcbPadStackParser.#parseFlags(mainRecord)
82
82
  const mainRecordTail =
83
83
  PcbPadStackParser.#parseMainRecordTail(mainRecord)
84
+ const extension = PcbPadStackParser.#parseExtensionRecord(
85
+ extensionRecord,
86
+ padContext
87
+ )
84
88
 
85
89
  return {
86
90
  ...flags,
@@ -90,8 +94,10 @@ export class PcbPadStackParser {
90
94
  mainRecordTail,
91
95
  padContext
92
96
  ),
93
- ...PcbPadStackParser.#parseExtensionRecord(
94
- extensionRecord,
97
+ ...extension,
98
+ ...PcbPadStackParser.#buildLocalStack(
99
+ mainRecordTail,
100
+ extension,
95
101
  padContext
96
102
  )
97
103
  }
@@ -722,6 +728,169 @@ export class PcbPadStackParser {
722
728
  }
723
729
  }
724
730
 
731
+ /**
732
+ * Builds a normalized local-stack geometry read model.
733
+ * @param {Record<string, boolean | number>} mainRecordTail Main tail fields.
734
+ * @param {Record<string, unknown>} extension Extension fields.
735
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
736
+ * @returns {{ localStack?: object }}
737
+ */
738
+ static #buildLocalStack(mainRecordTail, extension, padContext) {
739
+ const mode = Number(mainRecordTail.padMode)
740
+ if (mode === 1) {
741
+ return {
742
+ localStack: {
743
+ schema: 'altium-toolkit.pcb.pad-local-stack.a1',
744
+ mode,
745
+ modeName: String(mainRecordTail.padModeName || ''),
746
+ source: 'main-record',
747
+ layers: [
748
+ PcbPadStackParser.#localStackLayer(
749
+ 'top',
750
+ 1,
751
+ 'L1',
752
+ padContext,
753
+ extension
754
+ ),
755
+ PcbPadStackParser.#localStackLayer(
756
+ 'middle',
757
+ null,
758
+ 'INNER',
759
+ padContext,
760
+ extension
761
+ ),
762
+ PcbPadStackParser.#localStackLayer(
763
+ 'bottom',
764
+ 32,
765
+ 'L32',
766
+ padContext,
767
+ extension
768
+ )
769
+ ],
770
+ hole: PcbPadStackParser.#localStackHole(
771
+ padContext,
772
+ extension
773
+ )
774
+ }
775
+ }
776
+ }
777
+
778
+ if (
779
+ mode === 2 &&
780
+ Array.isArray(extension.fullStackLayerEntries) &&
781
+ extension.fullStackLayerEntries.length
782
+ ) {
783
+ return {
784
+ localStack: {
785
+ schema: 'altium-toolkit.pcb.pad-local-stack.a1',
786
+ mode,
787
+ modeName: String(mainRecordTail.padModeName || ''),
788
+ source: 'extension-record',
789
+ layers: extension.fullStackLayerEntries.map((entry) => ({
790
+ role: 'layer',
791
+ layerId: Number(entry.layerCode),
792
+ layerKey: 'L' + Number(entry.layerCode),
793
+ enabled: entry.enabled,
794
+ width: entry.sizeX,
795
+ height: entry.sizeY,
796
+ cornerRadius: entry.cornerRadius,
797
+ modeFlags: entry.modeFlags
798
+ })),
799
+ hole: PcbPadStackParser.#localStackHole(
800
+ padContext,
801
+ extension
802
+ )
803
+ }
804
+ }
805
+ }
806
+
807
+ return {}
808
+ }
809
+
810
+ /**
811
+ * Builds one top/middle/bottom local-stack layer entry.
812
+ * @param {'top' | 'middle' | 'bottom'} role Layer role.
813
+ * @param {number | null} layerId Layer id.
814
+ * @param {string} layerKey Stable layer key.
815
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
816
+ * @param {Record<string, unknown>} extension Extension fields.
817
+ * @returns {object}
818
+ */
819
+ static #localStackLayer(role, layerId, layerKey, padContext, extension) {
820
+ const suffix =
821
+ role === 'top' ? 'Top' : role === 'bottom' ? 'Bottom' : 'Mid'
822
+ const offset = PcbPadStackParser.#layerOffset(role, extension)
823
+
824
+ return {
825
+ role,
826
+ layerId,
827
+ layerKey,
828
+ width: Number(padContext['size' + suffix + 'X'] || 0),
829
+ height: Number(padContext['size' + suffix + 'Y'] || 0),
830
+ shape: PcbPadStackParser.#numericOrNull(
831
+ padContext['shape' + suffix]
832
+ ),
833
+ shapeName: PcbPadShapeCodec.padShapeName(
834
+ padContext['shape' + suffix]
835
+ ),
836
+ offsetX: offset.x,
837
+ offsetY: offset.y
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Resolves layer offsets from extension data when present.
843
+ * @param {'top' | 'middle' | 'bottom'} role Layer role.
844
+ * @param {Record<string, unknown>} extension Extension fields.
845
+ * @returns {{ x: number, y: number }}
846
+ */
847
+ static #layerOffset(role, extension) {
848
+ const layerNumber = role === 'top' ? 1 : role === 'bottom' ? 32 : null
849
+ const offset = Array.isArray(extension.layerOffsets)
850
+ ? extension.layerOffsets.find(
851
+ (entry) => entry.layerNumber === layerNumber
852
+ )
853
+ : null
854
+
855
+ return {
856
+ x: Number(offset?.x || 0),
857
+ y: Number(offset?.y || 0)
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Builds local-stack hole geometry.
863
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
864
+ * @param {Record<string, unknown>} extension Extension fields.
865
+ * @returns {object}
866
+ */
867
+ static #localStackHole(padContext, extension) {
868
+ const shape = PcbPadStackParser.#numericOrNull(extension.holeShape)
869
+
870
+ return {
871
+ diameter: Number(padContext.holeDiameter || 0),
872
+ shape,
873
+ shapeName:
874
+ shape === null ? null : PcbPadShapeCodec.holeShapeName(shape),
875
+ slotLength: extension.holeSlotLength ?? null,
876
+ rotation: extension.holeRotation ?? null
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Converts finite numeric values and nullish values into stable output.
882
+ * @param {unknown} value Candidate value.
883
+ * @returns {number | null}
884
+ */
885
+ static #numericOrNull(value) {
886
+ if (value === null || value === undefined || value === '') {
887
+ return null
888
+ }
889
+
890
+ const number = Number(value)
891
+ return Number.isFinite(number) ? number : null
892
+ }
893
+
725
894
  /**
726
895
  * Decodes non-empty inner-layer pad sizes.
727
896
  * @param {DataView} extensionRecord
@@ -15,10 +15,16 @@ export class PcbPickPlacePositionResolver {
15
15
  * @param {{ componentIndex: number, designator: string, pattern: string, layer: string, rotation: number, x: number, y: number }[]} components
16
16
  * @param {{ componentIndex: number, pads?: { x?: number, y?: number }[] }[]} componentPrimitiveGroups
17
17
  * @param {{ sourceComponents?: { componentIndex: number, rotation?: number }[] }} [options] Resolver options.
18
- * @returns {{ positionMode: string, entries: object[], modes: { componentOrigin: { positionMode: string, entries: object[] } } }}
18
+ * @returns {{ units: object, positionMode: string, entries: object[], modes: { componentOrigin: { units: object, positionMode: string, entries: object[] } } }}
19
19
  */
20
20
  static buildModel(components, componentPrimitiveGroups, options = {}) {
21
+ const units = {
22
+ coordinate: 'mil',
23
+ angle: 'deg'
24
+ }
25
+
21
26
  return {
27
+ units,
22
28
  positionMode: DEFAULT_POSITION_MODE,
23
29
  entries: PcbPickPlacePositionResolver.buildEntries(
24
30
  components,
@@ -28,6 +34,7 @@ export class PcbPickPlacePositionResolver {
28
34
  ),
29
35
  modes: {
30
36
  componentOrigin: {
37
+ units,
31
38
  positionMode: COMPONENT_ORIGIN_MODE,
32
39
  entries: PcbPickPlacePositionResolver.buildEntries(
33
40
  components,
@@ -137,6 +144,9 @@ export class PcbPickPlacePositionResolver {
137
144
  designator: component.designator || '',
138
145
  pattern: component.pattern || '',
139
146
  layer: component.layer || '',
147
+ ...(component.componentKind
148
+ ? { componentKind: component.componentKind }
149
+ : {}),
140
150
  rotation: PcbPickPlacePositionResolver.#roundCoordinate(rotation),
141
151
  x: PcbPickPlacePositionResolver.#roundCoordinate(position.x),
142
152
  y: PcbPickPlacePositionResolver.#roundCoordinate(position.y),
@@ -0,0 +1,338 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds read-only extraction manifests for placed PCB footprints.
7
+ */
8
+ export class PcbPlacedFootprintManifestBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.placed-footprint-extraction.a1'
10
+
11
+ /**
12
+ * Builds a placed-footprint extraction manifest.
13
+ * @param {{ fileName?: string, components?: object[], componentPrimitiveGroups?: object[], embeddedModels?: object[] }} context Manifest context.
14
+ * @returns {object}
15
+ */
16
+ static build(context = {}) {
17
+ const outputs = (context.componentPrimitiveGroups || []).map(
18
+ (group, index) =>
19
+ PcbPlacedFootprintManifestBuilder.#output(context, group, index)
20
+ )
21
+ const embeddedAssetCount = outputs.reduce(
22
+ (total, output) => total + output.embeddedAssets.length,
23
+ 0
24
+ )
25
+
26
+ return {
27
+ schema: PcbPlacedFootprintManifestBuilder.SCHEMA,
28
+ sourceDocument: String(context.fileName || ''),
29
+ summary: {
30
+ componentCount: (context.components || []).length,
31
+ extractableFootprintCount: outputs.length,
32
+ embeddedAssetCount
33
+ },
34
+ outputs,
35
+ indexes: PcbPlacedFootprintManifestBuilder.#indexes(outputs)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Builds one placed-footprint output descriptor.
41
+ * @param {object} context Manifest context.
42
+ * @param {object} group Component primitive group.
43
+ * @param {number} index Group index.
44
+ * @returns {object}
45
+ */
46
+ static #output(context, group, index) {
47
+ const component =
48
+ (context.components || []).find(
49
+ (candidate) =>
50
+ Number(candidate.componentIndex) ===
51
+ Number(group.componentIndex)
52
+ ) || {}
53
+ const designator = group.designator || component.designator || ''
54
+ const pattern = component.pattern || ''
55
+ const footprintKey =
56
+ 'footprint-extract-' +
57
+ index +
58
+ '-' +
59
+ PcbPlacedFootprintManifestBuilder.#slug(
60
+ [designator, pattern].filter(Boolean).join('-') || index
61
+ )
62
+
63
+ return {
64
+ kind: 'placed-footprint',
65
+ footprintKey,
66
+ designator,
67
+ pattern,
68
+ componentIndex: Number(group.componentIndex),
69
+ outputLibraryKey: 'pcb-extract/' + footprintKey + '.PcbLib',
70
+ renderManifestKey: 'pcb-extract/' + footprintKey + '.render.json',
71
+ primitiveCounts:
72
+ PcbPlacedFootprintManifestBuilder.#primitiveCounts(group),
73
+ layers: PcbPlacedFootprintManifestBuilder.#layers(group),
74
+ embeddedAssets: PcbPlacedFootprintManifestBuilder.#embeddedAssets(
75
+ group,
76
+ context.embeddedModels || []
77
+ ),
78
+ diagnostics: PcbPlacedFootprintManifestBuilder.#diagnostics(group)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Counts footprint-owned primitive families.
84
+ * @param {object} group Component primitive group.
85
+ * @returns {object}
86
+ */
87
+ static #primitiveCounts(group) {
88
+ return {
89
+ pads: (group.pads || []).length,
90
+ tracks: (group.tracks || []).length,
91
+ arcs: (group.arcs || []).length,
92
+ fills: (group.fills || []).length,
93
+ vias: (group.vias || []).length,
94
+ regions: (group.regions || []).length,
95
+ shapeBasedRegions: (group.shapeBasedRegions || []).length,
96
+ texts: (group.texts || []).length,
97
+ componentBodies: (group.componentBodies || []).length
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Builds layer descriptors touched by one footprint.
103
+ * @param {object} group Component primitive group.
104
+ * @returns {object[]}
105
+ */
106
+ static #layers(group) {
107
+ const layerMap = new Map()
108
+ for (const primitive of PcbPlacedFootprintManifestBuilder.#primitives(
109
+ group
110
+ )) {
111
+ const layer =
112
+ PcbPlacedFootprintManifestBuilder.#layerDescriptor(primitive)
113
+ if (layer) {
114
+ layerMap.set(layer.layerKey, layer)
115
+ }
116
+ }
117
+ return [...layerMap.values()].sort((left, right) =>
118
+ left.layerKey.localeCompare(right.layerKey, undefined, {
119
+ numeric: true
120
+ })
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Collects embedded assets referenced by component bodies.
126
+ * @param {object} group Component primitive group.
127
+ * @param {object[]} embeddedModels Embedded model rows.
128
+ * @returns {object[]}
129
+ */
130
+ static #embeddedAssets(group, embeddedModels) {
131
+ return PcbPlacedFootprintManifestBuilder.#dedupe(
132
+ (group.componentBodies || []).flatMap((body) => {
133
+ const model =
134
+ PcbPlacedFootprintManifestBuilder.#matchingModel(
135
+ body,
136
+ embeddedModels
137
+ ) || body
138
+ return [
139
+ PcbPlacedFootprintManifestBuilder.#stripUndefined({
140
+ key: model.id || body.modelId || model.name,
141
+ format: model.format,
142
+ sourceStream: model.sourceStream,
143
+ name: model.name || body.name
144
+ })
145
+ ]
146
+ })
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Builds extraction diagnostics for one group.
152
+ * @param {object} group Component primitive group.
153
+ * @returns {object[]}
154
+ */
155
+ static #diagnostics(group) {
156
+ const diagnostics = []
157
+ if (!PcbPlacedFootprintManifestBuilder.#hasOwnedGeometry(group)) {
158
+ diagnostics.push({
159
+ code: 'pcb-footprint-extract.empty-geometry',
160
+ severity: 'warning',
161
+ message: 'Placed component has no owned footprint geometry.'
162
+ })
163
+ }
164
+ return diagnostics
165
+ }
166
+
167
+ /**
168
+ * Builds manifest lookup indexes.
169
+ * @param {object[]} outputs Output descriptors.
170
+ * @returns {object}
171
+ */
172
+ static #indexes(outputs) {
173
+ const outputsByDesignator = {}
174
+ const outputsByPattern = {}
175
+
176
+ outputs.forEach((output, index) => {
177
+ if (output.designator)
178
+ outputsByDesignator[output.designator] = index
179
+ if (output.pattern) {
180
+ outputsByPattern[output.pattern] =
181
+ outputsByPattern[output.pattern] || []
182
+ outputsByPattern[output.pattern].push(index)
183
+ }
184
+ })
185
+
186
+ return {
187
+ outputsByDesignator,
188
+ outputsByPattern
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Returns all geometry primitives from a group.
194
+ * @param {object} group Component primitive group.
195
+ * @returns {object[]}
196
+ */
197
+ static #primitives(group) {
198
+ return [
199
+ ...(group.pads || []),
200
+ ...(group.tracks || []),
201
+ ...(group.arcs || []),
202
+ ...(group.fills || []),
203
+ ...(group.vias || []),
204
+ ...(group.regions || []),
205
+ ...(group.shapeBasedRegions || []),
206
+ ...(group.texts || [])
207
+ ]
208
+ }
209
+
210
+ /**
211
+ * Returns true when a component has any extractable geometry.
212
+ * @param {object} group Component primitive group.
213
+ * @returns {boolean}
214
+ */
215
+ static #hasOwnedGeometry(group) {
216
+ return (
217
+ PcbPlacedFootprintManifestBuilder.#primitives(group).length > 0 ||
218
+ (group.componentBodies || []).length > 0
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Builds a normalized layer descriptor.
224
+ * @param {object} primitive Primitive row.
225
+ * @returns {object | null}
226
+ */
227
+ static #layerDescriptor(primitive) {
228
+ const layerId = Number.isInteger(primitive?.layerId)
229
+ ? primitive.layerId
230
+ : null
231
+ const displayName = String(
232
+ primitive?.layerName || primitive?.layer || ''
233
+ ).trim()
234
+ if (layerId === null && !displayName) {
235
+ return null
236
+ }
237
+ const layerKey =
238
+ layerId === null
239
+ ? 'layer-' +
240
+ PcbPlacedFootprintManifestBuilder.#slug(displayName)
241
+ : 'L' + layerId
242
+
243
+ return PcbPlacedFootprintManifestBuilder.#stripUndefined({
244
+ layerKey,
245
+ layerId: layerId === null ? undefined : layerId,
246
+ displayName: displayName || layerKey
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Resolves an embedded model row for one component body.
252
+ * @param {object} body Component body row.
253
+ * @param {object[]} embeddedModels Embedded model rows.
254
+ * @returns {object | null}
255
+ */
256
+ static #matchingModel(body, embeddedModels) {
257
+ return (
258
+ (embeddedModels || []).find(
259
+ (model) =>
260
+ PcbPlacedFootprintManifestBuilder.#same(
261
+ model.id,
262
+ body.modelId
263
+ ) ||
264
+ PcbPlacedFootprintManifestBuilder.#same(
265
+ model.checksum,
266
+ body.checksum
267
+ ) ||
268
+ PcbPlacedFootprintManifestBuilder.#same(
269
+ model.name,
270
+ body.name
271
+ )
272
+ ) || null
273
+ )
274
+ }
275
+
276
+ /**
277
+ * Compares two non-empty values.
278
+ * @param {unknown} left First value.
279
+ * @param {unknown} right Second value.
280
+ * @returns {boolean}
281
+ */
282
+ static #same(left, right) {
283
+ return (
284
+ left !== null &&
285
+ left !== undefined &&
286
+ left !== '' &&
287
+ right !== null &&
288
+ right !== undefined &&
289
+ right !== '' &&
290
+ String(left) === String(right)
291
+ )
292
+ }
293
+
294
+ /**
295
+ * Deduplicates objects by their JSON form.
296
+ * @param {object[]} rows Candidate rows.
297
+ * @returns {object[]}
298
+ */
299
+ static #dedupe(rows) {
300
+ const seen = new Set()
301
+ const deduped = []
302
+ for (const row of rows || []) {
303
+ const key = JSON.stringify(row)
304
+ if (seen.has(key)) continue
305
+ seen.add(key)
306
+ deduped.push(row)
307
+ }
308
+ return deduped
309
+ }
310
+
311
+ /**
312
+ * Converts a value to a deterministic lowercase key segment.
313
+ * @param {unknown} value Source value.
314
+ * @returns {string}
315
+ */
316
+ static #slug(value) {
317
+ return (
318
+ String(value || '')
319
+ .trim()
320
+ .toLowerCase()
321
+ .replace(/[^a-z0-9]+/gu, '-')
322
+ .replace(/^-+|-+$/gu, '') || 'item'
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Removes undefined fields from one object.
328
+ * @param {Record<string, unknown>} value Candidate object.
329
+ * @returns {Record<string, unknown>}
330
+ */
331
+ static #stripUndefined(value) {
332
+ return Object.fromEntries(
333
+ Object.entries(value || {}).filter(
334
+ ([, entryValue]) => entryValue !== undefined
335
+ )
336
+ )
337
+ }
338
+ }
@@ -0,0 +1,120 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
6
+ import { ParserUtils } from './ParserUtils.mjs'
7
+
8
+ const { getField, parseBoolean, parseNumericField } = ParserUtils
9
+
10
+ /**
11
+ * Normalizes printable PCB polygon records into renderable polygon rows.
12
+ */
13
+ export class PcbPolygonRecordParser {
14
+ /**
15
+ * Parses polygon rows from printable records.
16
+ * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records Printable records.
17
+ * @returns {object[]}
18
+ */
19
+ static parse(records) {
20
+ return (records || [])
21
+ .filter(
22
+ (record) =>
23
+ record.sourceStream === 'Polygons6/Data' &&
24
+ getField(record.fields, 'KIND0')
25
+ )
26
+ .map((record, index) =>
27
+ PcbPolygonRecordParser.#normalize(record.fields, index)
28
+ )
29
+ .filter((polygon) => polygon.segments.length > 0)
30
+ }
31
+
32
+ /**
33
+ * Normalizes one printable polygon row.
34
+ * @param {Record<string, string | string[]>} fields Record fields.
35
+ * @param {number} index Fallback row index.
36
+ * @returns {object}
37
+ */
38
+ static #normalize(fields, index) {
39
+ const outline = AltiumLayoutParser.parseBoardOutline(fields)
40
+ return PcbPolygonRecordParser.#stripEmpty({
41
+ layer: getField(fields, 'LAYER') || 'UNKNOWN',
42
+ polygonIndex: PcbPolygonRecordParser.#firstNumber(fields, [
43
+ 'POLYGONINDEX',
44
+ 'POLYGON_INDEX',
45
+ 'INDEX'
46
+ ]),
47
+ subpolygonIndex: PcbPolygonRecordParser.#firstNumber(fields, [
48
+ 'SUBPOLYINDEX',
49
+ 'SUBPOLYGONINDEX',
50
+ 'SUB_POLYGON_INDEX'
51
+ ]),
52
+ unionIndex: PcbPolygonRecordParser.#firstNumber(fields, [
53
+ 'UNIONINDEX',
54
+ 'UNION_INDEX'
55
+ ]),
56
+ isCutout: PcbPolygonRecordParser.#firstBoolean(fields, [
57
+ 'ISCUTOUT',
58
+ 'IS_CUTOUT',
59
+ 'CUTOUT'
60
+ ]),
61
+ realizationKind:
62
+ getField(fields, 'POLYGONKIND') ||
63
+ getField(fields, 'POURKIND') ||
64
+ '',
65
+ sourceRecordIndex: index,
66
+ segments: outline.segments
67
+ })
68
+ }
69
+
70
+ /**
71
+ * Reads the first numeric field from a list of native aliases.
72
+ * @param {Record<string, string | string[]>} fields Record fields.
73
+ * @param {string[]} keys Candidate keys.
74
+ * @returns {number | undefined}
75
+ */
76
+ static #firstNumber(fields, keys) {
77
+ for (const key of keys) {
78
+ const value = parseNumericField(fields, key)
79
+ if (value !== null) {
80
+ return value
81
+ }
82
+ }
83
+
84
+ return undefined
85
+ }
86
+
87
+ /**
88
+ * Reads the first boolean field from a list of native aliases.
89
+ * @param {Record<string, string | string[]>} fields Record fields.
90
+ * @param {string[]} keys Candidate keys.
91
+ * @returns {boolean | undefined}
92
+ */
93
+ static #firstBoolean(fields, keys) {
94
+ for (const key of keys) {
95
+ if (getField(fields, key)) {
96
+ return parseBoolean(fields[key])
97
+ }
98
+ }
99
+
100
+ return undefined
101
+ }
102
+
103
+ /**
104
+ * Removes empty optional fields while preserving zero and false.
105
+ * @param {Record<string, unknown>} value Candidate object.
106
+ * @returns {Record<string, unknown>}
107
+ */
108
+ static #stripEmpty(value) {
109
+ return Object.fromEntries(
110
+ Object.entries(value || {}).filter(([, entryValue]) => {
111
+ if (Array.isArray(entryValue)) return entryValue.length > 0
112
+ return (
113
+ entryValue !== null &&
114
+ entryValue !== undefined &&
115
+ entryValue !== ''
116
+ )
117
+ })
118
+ )
119
+ }
120
+ }