altium-toolkit 1.1.23 → 1.1.25

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.
@@ -25,10 +25,17 @@ export class PcbLayerStackReadModelBuilder {
25
25
  const fields = PcbLayerStackReadModelBuilder.#mergeFields(
26
26
  input.boardRecords || []
27
27
  )
28
+ const nativeLayerOrder = (input.layers || []).length
29
+ ? null
30
+ : PcbLayerStackReadModelBuilder.#nativeLayerOrder(
31
+ fields,
32
+ input.primitiveLayers || []
33
+ )
28
34
  const layers = PcbLayerStackReadModelBuilder.#layers(
29
35
  input.layers || [],
30
36
  input.primitiveLayers || [],
31
- fields
37
+ fields,
38
+ nativeLayerOrder
32
39
  )
33
40
  const layerById = new Map(
34
41
  layers
@@ -85,6 +92,9 @@ export class PcbLayerStackReadModelBuilder {
85
92
  cavityRegionCount: cavityReport.cavityRegionCount,
86
93
  stiffenerLayerCount: cavityReport.stiffenerLayerCount,
87
94
  adhesiveLayerCount: cavityReport.adhesiveLayerCount,
95
+ ...(nativeLayerOrder
96
+ ? { nativeLayerOrderCount: nativeLayerOrder.layerIds.length }
97
+ : {}),
88
98
  diagnosticCount: diagnostics.length
89
99
  }
90
100
 
@@ -101,6 +111,7 @@ export class PcbLayerStackReadModelBuilder {
101
111
  topLevelBendLines,
102
112
  cavityReport
103
113
  ),
114
+ ...(nativeLayerOrder ? { nativeLayerOrder } : {}),
104
115
  layers,
105
116
  substacks,
106
117
  branches,
@@ -160,9 +171,10 @@ export class PcbLayerStackReadModelBuilder {
160
171
  * @param {object[]} layers Parsed physical layers.
161
172
  * @param {object[]} primitiveLayers Primitive layer map.
162
173
  * @param {Record<string, string | string[]>} fields Source fields.
174
+ * @param {{ layerIds: number[] } | null} nativeLayerOrder Native order.
163
175
  * @returns {object[]}
164
176
  */
165
- static #layers(layers, primitiveLayers, fields) {
177
+ static #layers(layers, primitiveLayers, fields, nativeLayerOrder) {
166
178
  if (layers.length) {
167
179
  return layers.map((layer) =>
168
180
  PcbLayerStackReadModelBuilder.#stripUndefined({
@@ -187,7 +199,19 @@ export class PcbLayerStackReadModelBuilder {
187
199
  )
188
200
  }
189
201
 
190
- return (primitiveLayers || []).map((layer, index) =>
202
+ const nativeOrderByLayerId = new Map(
203
+ (nativeLayerOrder?.layerIds || []).map((layerId, index) => [
204
+ layerId,
205
+ index + 1
206
+ ])
207
+ )
208
+ const orderedPrimitiveLayers =
209
+ PcbLayerStackReadModelBuilder.#orderPrimitiveLayers(
210
+ primitiveLayers || [],
211
+ nativeLayerOrder
212
+ )
213
+
214
+ return orderedPrimitiveLayers.map((layer, index) =>
191
215
  PcbLayerStackReadModelBuilder.#stripUndefined({
192
216
  index: index + 1,
193
217
  layerId: layer.layerId,
@@ -196,6 +220,7 @@ export class PcbLayerStackReadModelBuilder {
196
220
  : undefined,
197
221
  name: layer.name,
198
222
  kind: layer.kind || layer.role,
223
+ nativeOrderIndex: nativeOrderByLayerId.get(layer.layerId),
199
224
  ...PcbLayerStackSourceMetadataParser.layerSourceFields(
200
225
  fields,
201
226
  index + 1
@@ -204,6 +229,116 @@ export class PcbLayerStackReadModelBuilder {
204
229
  )
205
230
  }
206
231
 
232
+ /**
233
+ * Parses the native layer linked-list order stored in Board6 records.
234
+ * @param {Record<string, string | string[]>} fields Board fields.
235
+ * @param {object[]} primitiveLayers Primitive layer fallbacks.
236
+ * @returns {{ source: string, layerIds: number[], complete: boolean } | null}
237
+ */
238
+ static #nativeLayerOrder(fields, primitiveLayers) {
239
+ const layerIds = new Set(
240
+ (primitiveLayers || [])
241
+ .map((layer) => Number(layer.layerId))
242
+ .filter(Number.isFinite)
243
+ )
244
+ if (!layerIds.size) return null
245
+
246
+ const links = PcbLayerStackReadModelBuilder.#nativeLayerLinks(fields)
247
+ const firstLayerId = [...links.entries()].find(
248
+ ([, link]) => link.prev === 0 && link.next !== 0
249
+ )?.[0]
250
+ if (!firstLayerId) return null
251
+
252
+ const orderedLayerIds = []
253
+ const visited = new Set()
254
+ let currentLayerId = firstLayerId
255
+
256
+ while (
257
+ Number.isFinite(currentLayerId) &&
258
+ currentLayerId !== 0 &&
259
+ !visited.has(currentLayerId)
260
+ ) {
261
+ visited.add(currentLayerId)
262
+
263
+ if (layerIds.has(currentLayerId)) {
264
+ orderedLayerIds.push(currentLayerId)
265
+ }
266
+
267
+ const nextLayerId = links.get(currentLayerId)?.next || 0
268
+ currentLayerId = Number(nextLayerId)
269
+ }
270
+
271
+ if (!orderedLayerIds.length) return null
272
+
273
+ return {
274
+ source: 'Board6/Data',
275
+ layerIds: orderedLayerIds,
276
+ complete: orderedLayerIds.length === layerIds.size
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Builds native layer link rows from Board6 LAYERnNEXT/PREV fields.
282
+ * @param {Record<string, string | string[]>} fields Board fields.
283
+ * @returns {Map<number, { next: number, prev: number }>}
284
+ */
285
+ static #nativeLayerLinks(fields) {
286
+ const layerIndexes = PcbLayerStackReadModelBuilder.#indexedRows(
287
+ fields,
288
+ [/^LAYER(\d+)NEXT$/iu, /^LAYER(\d+)PREV$/iu]
289
+ )
290
+ const links = new Map()
291
+
292
+ for (const layerIndex of layerIndexes) {
293
+ const next = Number(
294
+ PcbLayerStackReadModelBuilder.#field(
295
+ fields,
296
+ 'LAYER' + layerIndex + 'NEXT'
297
+ ) || 0
298
+ )
299
+ const prev = Number(
300
+ PcbLayerStackReadModelBuilder.#field(
301
+ fields,
302
+ 'LAYER' + layerIndex + 'PREV'
303
+ ) || 0
304
+ )
305
+
306
+ if (Number.isFinite(next) || Number.isFinite(prev)) {
307
+ links.set(layerIndex, {
308
+ next: Number.isFinite(next) ? next : 0,
309
+ prev: Number.isFinite(prev) ? prev : 0
310
+ })
311
+ }
312
+ }
313
+
314
+ return links
315
+ }
316
+
317
+ /**
318
+ * Orders primitive fallback layers with native linked-list evidence first.
319
+ * @param {object[]} primitiveLayers Primitive layer rows.
320
+ * @param {{ layerIds: number[] } | null} nativeLayerOrder Native order.
321
+ * @returns {object[]}
322
+ */
323
+ static #orderPrimitiveLayers(primitiveLayers, nativeLayerOrder) {
324
+ if (!nativeLayerOrder?.layerIds?.length) return primitiveLayers
325
+
326
+ const layerById = new Map(
327
+ primitiveLayers
328
+ .filter((layer) => Number.isFinite(Number(layer.layerId)))
329
+ .map((layer) => [Number(layer.layerId), layer])
330
+ )
331
+ const orderedLayers = nativeLayerOrder.layerIds
332
+ .map((layerId) => layerById.get(layerId))
333
+ .filter(Boolean)
334
+ const orderedIds = new Set(nativeLayerOrder.layerIds)
335
+ const remainingLayers = primitiveLayers.filter(
336
+ (layer) => !orderedIds.has(Number(layer.layerId))
337
+ )
338
+
339
+ return [...orderedLayers, ...remainingLayers]
340
+ }
341
+
207
342
  /**
208
343
  * Normalizes substacks and links them to board-region rows.
209
344
  * @param {object[]} layerSubstacks Parsed substacks.
@@ -0,0 +1,352 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds byte-offset evidence for preserved PCB primitive records.
7
+ */
8
+ export class PcbPrimitiveFieldOffsetReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.primitive-field-offsets.a1'
10
+
11
+ static #DEFAULT_FIELD_MAPS = Object.freeze([
12
+ PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
13
+ 'Tracks6/Data',
14
+ 'payload',
15
+ [
16
+ ['layerId', 0, 1, 'uint8'],
17
+ ['flags', 1, 1, 'uint8'],
18
+ ['keepout', 2, 1, 'uint8'],
19
+ ['netIndex', 3, 2, 'int16-le'],
20
+ ['polygonIndex', 5, 2, 'int16-le'],
21
+ ['componentIndex', 7, 2, 'int16-le'],
22
+ ['x1', 13, 4, 'mil-int32-le'],
23
+ ['y1', 17, 4, 'mil-int32-le'],
24
+ ['x2', 21, 4, 'mil-int32-le'],
25
+ ['y2', 25, 4, 'mil-int32-le'],
26
+ ['width', 29, 4, 'mil-int32-le'],
27
+ ['unionIndex', 36, 1, 'uint8'],
28
+ ['lengthTuning', 37, 1, 'uint8'],
29
+ ['userRouted', 44, 1, 'uint8']
30
+ ]
31
+ ),
32
+ PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
33
+ 'Arcs6/Data',
34
+ 'payload',
35
+ [
36
+ ['layerId', 0, 1, 'uint8'],
37
+ ['netIndex', 3, 2, 'int16-le'],
38
+ ['polygonIndex', 5, 2, 'int16-le'],
39
+ ['componentIndex', 7, 2, 'int16-le'],
40
+ ['x', 13, 4, 'mil-int32-le'],
41
+ ['y', 17, 4, 'mil-int32-le'],
42
+ ['radius', 21, 4, 'mil-int32-le'],
43
+ ['startAngle', 25, 8, 'float64-le'],
44
+ ['endAngle', 33, 8, 'float64-le'],
45
+ ['width', 41, 4, 'mil-int32-le']
46
+ ]
47
+ ),
48
+ PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
49
+ 'Fills6/Data',
50
+ 'record',
51
+ [
52
+ ['layerId', 5, 1, 'uint8'],
53
+ ['netIndex', 8, 2, 'int16-le'],
54
+ ['polygonIndex', 10, 2, 'int16-le'],
55
+ ['componentIndex', 12, 2, 'int16-le'],
56
+ ['x1', 18, 4, 'mil-int32-le'],
57
+ ['y1', 22, 4, 'mil-int32-le'],
58
+ ['x2', 26, 4, 'mil-int32-le'],
59
+ ['y2', 30, 4, 'mil-int32-le'],
60
+ ['layerCode', 46, 2, 'uint16-le']
61
+ ]
62
+ ),
63
+ PcbPrimitiveFieldOffsetReportBuilder.#fieldMap('Vias6/Data', 'record', [
64
+ ['layerId', 5, 1, 'uint8'],
65
+ ['netIndex', 8, 2, 'int16-le'],
66
+ ['polygonIndex', 10, 2, 'int16-le'],
67
+ ['componentIndex', 12, 2, 'int16-le'],
68
+ ['x', 18, 4, 'mil-int32-le'],
69
+ ['y', 22, 4, 'mil-int32-le'],
70
+ ['diameter', 26, 4, 'mil-int32-le'],
71
+ ['holeDiameter', 30, 4, 'mil-int32-le'],
72
+ ['layerStartId', 34, 1, 'uint8'],
73
+ ['layerEndId', 35, 1, 'uint8']
74
+ ])
75
+ ])
76
+
77
+ /**
78
+ * Builds a field-offset report for preserved primitive records.
79
+ * @param {{ fileName?: string, rawRecords?: object[], records?: object[], fieldMaps?: object[] }} [input]
80
+ * @returns {object}
81
+ */
82
+ static build(input = {}) {
83
+ const records = PcbPrimitiveFieldOffsetReportBuilder.#records(input)
84
+ const fieldMaps = [
85
+ ...PcbPrimitiveFieldOffsetReportBuilder.#DEFAULT_FIELD_MAPS,
86
+ ...PcbPrimitiveFieldOffsetReportBuilder.#normalizeFieldMaps(
87
+ input.fieldMaps || []
88
+ )
89
+ ]
90
+ const fields = []
91
+ const unmatchedRecords = []
92
+
93
+ for (const record of records) {
94
+ const fieldMap =
95
+ PcbPrimitiveFieldOffsetReportBuilder.#matchingFieldMap(
96
+ record,
97
+ fieldMaps
98
+ )
99
+
100
+ if (!fieldMap) {
101
+ unmatchedRecords.push(
102
+ PcbPrimitiveFieldOffsetReportBuilder.#recordKey(record)
103
+ )
104
+ continue
105
+ }
106
+
107
+ for (const field of fieldMap.fields) {
108
+ fields.push(
109
+ PcbPrimitiveFieldOffsetReportBuilder.#fieldRow(
110
+ record,
111
+ fieldMap,
112
+ field
113
+ )
114
+ )
115
+ }
116
+ }
117
+
118
+ const streams =
119
+ PcbPrimitiveFieldOffsetReportBuilder.#streamSummaries(fields)
120
+
121
+ return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
122
+ schema: PcbPrimitiveFieldOffsetReportBuilder.SCHEMA,
123
+ sourceDocument: input.fileName || undefined,
124
+ summary: {
125
+ recordCount: records.length,
126
+ mappedRecordCount: records.length - unmatchedRecords.length,
127
+ fieldCount: fields.length,
128
+ streamCount: streams.length,
129
+ unmatchedRecordCount: unmatchedRecords.length
130
+ },
131
+ streams,
132
+ fields,
133
+ unmatchedRecords
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Creates one built-in field-map descriptor.
139
+ * @param {string} sourceStream Native stream path.
140
+ * @param {'record' | 'payload'} relativeTo Offset origin.
141
+ * @param {Array<[string, number, number, string]>} fieldRows Field rows.
142
+ * @returns {object}
143
+ */
144
+ static #fieldMap(sourceStream, relativeTo, fieldRows) {
145
+ return Object.freeze({
146
+ sourceStream,
147
+ relativeTo,
148
+ fields: Object.freeze(
149
+ fieldRows.map(([name, offset, byteLength, encoding]) =>
150
+ Object.freeze({
151
+ name,
152
+ offset,
153
+ byteLength,
154
+ encoding
155
+ })
156
+ )
157
+ )
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Normalizes record input.
163
+ * @param {object} input Report input.
164
+ * @returns {object[]}
165
+ */
166
+ static #records(input) {
167
+ if (Array.isArray(input.rawRecords)) return input.rawRecords
168
+ if (Array.isArray(input.records)) return input.records
169
+ return []
170
+ }
171
+
172
+ /**
173
+ * Normalizes caller-provided field maps.
174
+ * @param {object[]} fieldMaps Field maps.
175
+ * @returns {object[]}
176
+ */
177
+ static #normalizeFieldMaps(fieldMaps) {
178
+ return fieldMaps
179
+ .filter((fieldMap) => fieldMap?.sourceStream)
180
+ .map((fieldMap) => ({
181
+ sourceStream: String(fieldMap.sourceStream),
182
+ family: fieldMap.family ? String(fieldMap.family) : undefined,
183
+ type: fieldMap.type ? String(fieldMap.type) : undefined,
184
+ relativeTo:
185
+ fieldMap.relativeTo === 'record' ? 'record' : 'payload',
186
+ fields: (Array.isArray(fieldMap.fields) ? fieldMap.fields : [])
187
+ .filter((field) => field?.name)
188
+ .map((field) =>
189
+ PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
190
+ name: String(field.name),
191
+ offset: Number(field.offset || 0),
192
+ byteLength: Number(field.byteLength || 0),
193
+ encoding: field.encoding
194
+ ? String(field.encoding)
195
+ : undefined,
196
+ relativeTo:
197
+ field.relativeTo === 'record' ||
198
+ field.relativeTo === 'payload'
199
+ ? field.relativeTo
200
+ : undefined
201
+ })
202
+ )
203
+ }))
204
+ }
205
+
206
+ /**
207
+ * Finds the field-map matching one raw record.
208
+ * @param {object} record Raw record row.
209
+ * @param {object[]} fieldMaps Field-map descriptors.
210
+ * @returns {object | undefined}
211
+ */
212
+ static #matchingFieldMap(record, fieldMaps) {
213
+ return fieldMaps.find((fieldMap) => {
214
+ if (fieldMap.sourceStream !== record.sourceStream) return false
215
+ if (fieldMap.family && fieldMap.family !== record.family) {
216
+ return false
217
+ }
218
+ if (fieldMap.type && fieldMap.type !== record.type) return false
219
+ return true
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Builds one field evidence row.
225
+ * @param {object} record Raw record row.
226
+ * @param {object} fieldMap Field-map descriptor.
227
+ * @param {object} field Field descriptor.
228
+ * @returns {object}
229
+ */
230
+ static #fieldRow(record, fieldMap, field) {
231
+ const relativeTo = field.relativeTo || fieldMap.relativeTo
232
+ const baseOffset =
233
+ Number(record.offset || 0) +
234
+ PcbPrimitiveFieldOffsetReportBuilder.#relativeOffset(
235
+ record,
236
+ relativeTo
237
+ )
238
+ const offset = Number(field.offset || 0)
239
+ const byteLength = Number(field.byteLength || 0)
240
+ const withinRecordOffset = baseOffset - Number(record.offset || 0)
241
+
242
+ return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
243
+ sourceStream: record.sourceStream,
244
+ family: record.family,
245
+ type: record.type,
246
+ typeId: PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
247
+ record.typeId
248
+ ),
249
+ recordIndex:
250
+ PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
251
+ record.recordIndex
252
+ ),
253
+ name: field.name,
254
+ relativeTo,
255
+ offset,
256
+ absoluteOffset: baseOffset + offset,
257
+ byteLength,
258
+ endOffset: baseOffset + offset + byteLength,
259
+ encoding: field.encoding,
260
+ available:
261
+ byteLength > 0 &&
262
+ withinRecordOffset + offset + byteLength <=
263
+ Number(record.byteLength || 0)
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Returns the offset of a field origin within a raw record.
269
+ * @param {object} record Raw record row.
270
+ * @param {'record' | 'payload'} relativeTo Offset origin.
271
+ * @returns {number}
272
+ */
273
+ static #relativeOffset(record, relativeTo) {
274
+ if (relativeTo !== 'payload') return 0
275
+ if (record.encoding === 'length-prefixed') return 5
276
+ return 0
277
+ }
278
+
279
+ /**
280
+ * Summarizes mapped fields by source stream.
281
+ * @param {object[]} fields Field rows.
282
+ * @returns {object[]}
283
+ */
284
+ static #streamSummaries(fields) {
285
+ const byStream = new Map()
286
+
287
+ for (const field of fields) {
288
+ if (!byStream.has(field.sourceStream)) {
289
+ byStream.set(field.sourceStream, {
290
+ sourceStream: field.sourceStream,
291
+ recordIndexes: new Set(),
292
+ fieldNames: [],
293
+ fieldNameSet: new Set()
294
+ })
295
+ }
296
+
297
+ const summary = byStream.get(field.sourceStream)
298
+ summary.recordIndexes.add(field.recordIndex)
299
+ if (!summary.fieldNameSet.has(field.name)) {
300
+ summary.fieldNameSet.add(field.name)
301
+ summary.fieldNames.push(field.name)
302
+ }
303
+ }
304
+
305
+ return [...byStream.values()].map((summary) => ({
306
+ sourceStream: summary.sourceStream,
307
+ recordCount: summary.recordIndexes.size,
308
+ fieldCount: fields.filter(
309
+ (field) => field.sourceStream === summary.sourceStream
310
+ ).length,
311
+ fieldNames: summary.fieldNames
312
+ }))
313
+ }
314
+
315
+ /**
316
+ * Builds a compact record key for unmatched records.
317
+ * @param {object} record Raw record row.
318
+ * @returns {object}
319
+ */
320
+ static #recordKey(record) {
321
+ return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
322
+ sourceStream: record.sourceStream,
323
+ family: record.family,
324
+ type: record.type,
325
+ recordIndex:
326
+ PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
327
+ record.recordIndex
328
+ )
329
+ })
330
+ }
331
+
332
+ /**
333
+ * Returns a finite number or undefined.
334
+ * @param {unknown} value Candidate number.
335
+ * @returns {number | undefined}
336
+ */
337
+ static #finiteOrUndefined(value) {
338
+ const number = Number(value)
339
+ return Number.isFinite(number) ? number : undefined
340
+ }
341
+
342
+ /**
343
+ * Removes undefined values from a report row.
344
+ * @param {object} row Report row.
345
+ * @returns {object}
346
+ */
347
+ static #stripUndefined(row) {
348
+ return Object.fromEntries(
349
+ Object.entries(row).filter(([, value]) => value !== undefined)
350
+ )
351
+ }
352
+ }