altium-toolkit 1.1.24 → 1.1.26

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 (35) hide show
  1. package/README.md +5 -3
  2. package/docs/api.md +43 -17
  3. package/docs/model-format.md +51 -11
  4. package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
  5. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
  6. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
  7. package/package.json +1 -1
  8. package/spec/library-compatibility.md +65 -0
  9. package/src/core/altium/AltiumParser.mjs +8 -1
  10. package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
  11. package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
  12. package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
  13. package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
  14. package/src/core/altium/PcbDimensionParser.mjs +1 -0
  15. package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
  17. package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
  18. package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
  19. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
  20. package/src/core/altium/PcbLibModelParser.mjs +24 -2
  21. package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
  23. package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
  24. package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
  25. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
  26. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
  27. package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
  28. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
  29. package/src/parser.mjs +8 -0
  30. package/src/styles/altium-renderers.css +36 -0
  31. package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
  32. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
  33. package/src/ui/PcbScene3dBuilder.mjs +10 -2
  34. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
  35. package/src/ui/PcbSvgRenderer.mjs +78 -2
@@ -2,6 +2,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { LibraryCompatibilityReportBuilder } from './LibraryCompatibilityReportBuilder.mjs'
6
+
5
7
  /**
6
8
  * Builds deterministic QA reports across parsed schematic and PCB libraries.
7
9
  */
@@ -33,6 +35,10 @@ export class LibraryQaReportBuilder {
33
35
  schematicLibraries,
34
36
  pcbLibraries
35
37
  )
38
+ const compatibility = LibraryCompatibilityReportBuilder.build({
39
+ schematicLibraries,
40
+ pcbLibraries
41
+ })
36
42
  const mergePlan =
37
43
  LibraryQaReportBuilder.#schematicLibraryMergePlan(
38
44
  schematicLibraries
@@ -74,7 +80,8 @@ export class LibraryQaReportBuilder {
74
80
  diagnostic.symbolName
75
81
  )
76
82
  ),
77
- ...libraryLint.issues
83
+ ...libraryLint.issues,
84
+ ...(compatibility.issues || [])
78
85
  ]
79
86
  const issuesBySeverity =
80
87
  LibraryQaReportBuilder.#issueSeverityCounts(issues)
@@ -91,6 +98,7 @@ export class LibraryQaReportBuilder {
91
98
  multipartMismatchCount: multipartMismatches.length,
92
99
  mergePlanConflictCount: mergePlan.summary.conflictCount,
93
100
  libraryLintIssueCount: libraryLint.summary.issueCount,
101
+ ...LibraryQaReportBuilder.#compatibilitySummary(compatibility),
94
102
  issuesBySeverity,
95
103
  issueCount: issues.length
96
104
  },
@@ -102,6 +110,7 @@ export class LibraryQaReportBuilder {
102
110
  missingModels,
103
111
  multipartMismatches,
104
112
  libraryLint,
113
+ compatibility,
105
114
  mergePlan,
106
115
  issues
107
116
  }
@@ -680,6 +689,25 @@ export class LibraryQaReportBuilder {
680
689
  return counts
681
690
  }
682
691
 
692
+ /**
693
+ * Builds optional compatibility counters for non-empty reports.
694
+ * @param {object} compatibility Compatibility report.
695
+ * @returns {object}
696
+ */
697
+ static #compatibilitySummary(compatibility) {
698
+ const summary = compatibility?.summary || {}
699
+ const issueCount = Number(summary.issueCount || 0)
700
+
701
+ if (!issueCount) return {}
702
+
703
+ return {
704
+ compatibilityIssueCount: issueCount,
705
+ hiddenPinCount: Number(summary.hiddenPinCount || 0),
706
+ padDiagnosticCount: Number(summary.padDiagnosticCount || 0),
707
+ modelSuggestionCount: Number(summary.modelSuggestionCount || 0)
708
+ }
709
+ }
710
+
683
711
  /**
684
712
  * Builds duplicate-symbol merge-plan rows.
685
713
  * @param {object[]} schematicLibraries Schematic libraries.
@@ -173,6 +173,7 @@ export class PcbDimensionParser {
173
173
  static #normalizeKind(kindCode) {
174
174
  const normalized = String(kindCode || '').toLowerCase()
175
175
  if (/ang/u.test(normalized)) return 'angular'
176
+ if (/diam/u.test(normalized)) return 'diameter'
176
177
  if (/radial|radius/u.test(normalized)) return 'radial'
177
178
  if (/datum/u.test(normalized)) return 'datum'
178
179
  if (/baseline/u.test(normalized)) return 'baseline'
@@ -0,0 +1,190 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic QA summaries for recovered PCB dimension records.
7
+ */
8
+ export class PcbDimensionReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.dimensions.a1'
10
+
11
+ /**
12
+ * Builds a dimension QA report from a parser root or PCB model.
13
+ * @param {object} input Parser root, PCB model, or options object.
14
+ * @returns {object}
15
+ */
16
+ static build(input = {}) {
17
+ const pcb = PcbDimensionReportBuilder.#pcb(input)
18
+ const dimensions = PcbDimensionReportBuilder.#dimensions(pcb)
19
+ const rows = dimensions.map((dimension) =>
20
+ PcbDimensionReportBuilder.#dimensionRow(dimension)
21
+ )
22
+ const findings = rows.flatMap((row) =>
23
+ PcbDimensionReportBuilder.#findingsForRow(row)
24
+ )
25
+
26
+ return {
27
+ schema: PcbDimensionReportBuilder.SCHEMA,
28
+ summary: PcbDimensionReportBuilder.#summary(rows),
29
+ dimensions: rows,
30
+ findings
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolves the PCB payload from a parser root or direct PCB model.
36
+ * @param {object} input Parser root or PCB model.
37
+ * @returns {object}
38
+ */
39
+ static #pcb(input) {
40
+ return input?.pcb || input || {}
41
+ }
42
+
43
+ /**
44
+ * Resolves normalized dimension rows from a PCB payload.
45
+ * @param {object} pcb PCB model.
46
+ * @returns {object[]}
47
+ */
48
+ static #dimensions(pcb) {
49
+ return Array.isArray(pcb?.dimensions) ? pcb.dimensions : []
50
+ }
51
+
52
+ /**
53
+ * Builds one public dimension report row.
54
+ * @param {object} dimension Normalized dimension record.
55
+ * @returns {object}
56
+ */
57
+ static #dimensionRow(dimension) {
58
+ const kind = PcbDimensionReportBuilder.#kind(dimension)
59
+ const references = Array.isArray(dimension?.references)
60
+ ? dimension.references
61
+ : []
62
+ const requiredReferenceCount =
63
+ PcbDimensionReportBuilder.#requiredReferenceCount(kind)
64
+ const status =
65
+ references.length >= requiredReferenceCount
66
+ ? 'renderable'
67
+ : 'unresolved'
68
+
69
+ return PcbDimensionReportBuilder.#stripEmpty({
70
+ dimensionIndex: PcbDimensionReportBuilder.#integerOrFallback(
71
+ dimension?.dimensionIndex,
72
+ 0
73
+ ),
74
+ kind,
75
+ name: dimension?.name || '',
76
+ layer: dimension?.layer || '',
77
+ text: dimension?.text || '',
78
+ status,
79
+ referenceCount: references.length,
80
+ requiredReferenceCount,
81
+ hasTextLocation: Boolean(dimension?.textLocation),
82
+ measuredValue: dimension?.measuredValue ?? undefined,
83
+ angleValue: dimension?.angleValue ?? undefined,
84
+ unit: dimension?.unit || ''
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Builds report findings for one dimension row.
90
+ * @param {object} row Dimension report row.
91
+ * @returns {object[]}
92
+ */
93
+ static #findingsForRow(row) {
94
+ if (row.status !== 'unresolved') {
95
+ return []
96
+ }
97
+
98
+ return [
99
+ PcbDimensionReportBuilder.#stripEmpty({
100
+ code: 'pcb.dimension.missing-reference',
101
+ severity: 'warning',
102
+ dimensionIndex: row.dimensionIndex,
103
+ kind: row.kind,
104
+ name: row.name,
105
+ referenceCount: row.referenceCount,
106
+ requiredReferenceCount: row.requiredReferenceCount
107
+ })
108
+ ]
109
+ }
110
+
111
+ /**
112
+ * Builds top-level dimension counters.
113
+ * @param {object[]} rows Dimension report rows.
114
+ * @returns {object}
115
+ */
116
+ static #summary(rows) {
117
+ const byKind = new Map()
118
+
119
+ for (const row of rows) {
120
+ byKind.set(row.kind, Number(byKind.get(row.kind) || 0) + 1)
121
+ }
122
+
123
+ return {
124
+ dimensionCount: rows.length,
125
+ renderableCount: rows.filter((row) => row.status === 'renderable')
126
+ .length,
127
+ unresolvedCount: rows.filter((row) => row.status === 'unresolved')
128
+ .length,
129
+ missingTextLocationCount: rows.filter(
130
+ (row) => row.hasTextLocation !== true
131
+ ).length,
132
+ byKind: [...byKind.entries()]
133
+ .map(([kind, count]) => ({ kind, count }))
134
+ .sort((left, right) =>
135
+ left.kind.localeCompare(right.kind, undefined, {
136
+ numeric: true
137
+ })
138
+ )
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Resolves the canonical dimension kind.
144
+ * @param {object} dimension Dimension record.
145
+ * @returns {string}
146
+ */
147
+ static #kind(dimension) {
148
+ const kind = String(dimension?.kind || '')
149
+ .trim()
150
+ .toLowerCase()
151
+ return kind || 'linear'
152
+ }
153
+
154
+ /**
155
+ * Returns the minimum reference count needed for deterministic rendering.
156
+ * @param {string} kind Dimension kind.
157
+ * @returns {number}
158
+ */
159
+ static #requiredReferenceCount(kind) {
160
+ if (kind === 'angular') {
161
+ return 3
162
+ }
163
+
164
+ return 2
165
+ }
166
+
167
+ /**
168
+ * Parses an integer with a fallback.
169
+ * @param {unknown} value Numeric candidate.
170
+ * @param {number} fallback Fallback value.
171
+ * @returns {number}
172
+ */
173
+ static #integerOrFallback(value, fallback) {
174
+ const number = Number(value)
175
+ return Number.isInteger(number) ? number : fallback
176
+ }
177
+
178
+ /**
179
+ * Removes undefined and blank-string values from a shallow object.
180
+ * @param {Record<string, unknown>} row Input row.
181
+ * @returns {Record<string, unknown>}
182
+ */
183
+ static #stripEmpty(row) {
184
+ return Object.fromEntries(
185
+ Object.entries(row).filter(
186
+ ([, value]) => value !== undefined && value !== ''
187
+ )
188
+ )
189
+ }
190
+ }
@@ -7,6 +7,7 @@
7
7
  // specifier.
8
8
  import { unzlibSync } from 'fflate'
9
9
  import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
10
+ import { PcbShapeBasedBodyGeometryParser } from './PcbShapeBasedBodyGeometryParser.mjs'
10
11
 
11
12
  /**
12
13
  * Extracts embedded 3D model payloads and component-body placement metadata
@@ -17,7 +18,7 @@ export class PcbEmbeddedModelExtractor {
17
18
  * Extracts embedded model payloads and component-body placements from one
18
19
  * stream map.
19
20
  * @param {Map<string, Uint8Array>} streams
20
- * @returns {{ models: { id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } }[], componentBodies: { sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[] }}
21
+ * @returns {{ models: { id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } }[], componentBodies: { sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object }[] }}
21
22
  */
22
23
  static extractFromStreams(streams) {
23
24
  const modelStreamPrefix =
@@ -51,7 +52,7 @@ export class PcbEmbeddedModelExtractor {
51
52
  streams.get('ComponentBodies6/Data'),
52
53
  'ComponentBodies6/Data'
53
54
  ),
54
- ...PcbEmbeddedModelExtractor.#parseComponentBodyStream(
55
+ ...PcbEmbeddedModelExtractor.#parseShapeBasedComponentBodyStream(
55
56
  streams.get('ShapeBasedComponentBodies6/Data'),
56
57
  'ShapeBasedComponentBodies6/Data'
57
58
  )
@@ -134,9 +135,10 @@ export class PcbEmbeddedModelExtractor {
134
135
  * Parses one component-body printable stream into model-placement records.
135
136
  * @param {Uint8Array | undefined} bytes
136
137
  * @param {string} sourceStream
137
- * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
138
+ * @param {{ inferStaticGeometry?: boolean }} [options]
139
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object }[]}
138
140
  */
139
- static #parseComponentBodyStream(bytes, sourceStream) {
141
+ static #parseComponentBodyStream(bytes, sourceStream, options = {}) {
140
142
  if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
141
143
  return []
142
144
  }
@@ -153,12 +155,53 @@ export class PcbEmbeddedModelExtractor {
153
155
  .map((fields) =>
154
156
  PcbEmbeddedModelExtractor.#normalizeComponentBody(
155
157
  fields,
156
- sourceStream
158
+ sourceStream,
159
+ options.inferStaticGeometry
160
+ ? {
161
+ staticGeometry:
162
+ PcbShapeBasedBodyGeometryParser.buildStaticGeometry(
163
+ fields,
164
+ []
165
+ )
166
+ }
167
+ : {}
157
168
  )
158
169
  )
159
170
  .filter(Boolean)
160
171
  }
161
172
 
173
+ /**
174
+ * Parses shape-based component bodies, including optional vertex payloads.
175
+ * @param {Uint8Array | undefined} bytes
176
+ * @param {string} sourceStream
177
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object }[]}
178
+ */
179
+ static #parseShapeBasedComponentBodyStream(bytes, sourceStream) {
180
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
181
+ return []
182
+ }
183
+
184
+ const records = PcbShapeBasedBodyGeometryParser.parse(bytes)
185
+
186
+ if (records.length) {
187
+ return records
188
+ .map((record) =>
189
+ PcbEmbeddedModelExtractor.#normalizeComponentBody(
190
+ record.fields,
191
+ sourceStream,
192
+ { staticGeometry: record.staticGeometry }
193
+ )
194
+ )
195
+ .filter(Boolean)
196
+ }
197
+
198
+ return PcbEmbeddedModelExtractor.#parseComponentBodyStream(
199
+ bytes,
200
+ sourceStream,
201
+ { inferStaticGeometry: true }
202
+ )
203
+ }
204
+
162
205
  /**
163
206
  * Parses one printable field record without requiring a specific leading
164
207
  * marker such as `|RECORD=` or `|KIND=`.
@@ -266,9 +309,10 @@ export class PcbEmbeddedModelExtractor {
266
309
  * Normalizes one component-body record into model-placement metadata.
267
310
  * @param {Record<string, string | string[]>} fields
268
311
  * @param {string} sourceStream
269
- * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null } | null}
312
+ * @param {{ staticGeometry?: object }} [extra]
313
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object } | null}
270
314
  */
271
- static #normalizeComponentBody(fields, sourceStream) {
315
+ static #normalizeComponentBody(fields, sourceStream, extra = {}) {
272
316
  const modelId = PcbEmbeddedModelExtractor.#getField(fields, 'MODELID')
273
317
  const name = PcbEmbeddedModelExtractor.#getField(fields, 'MODEL.NAME')
274
318
 
@@ -339,7 +383,82 @@ export class PcbEmbeddedModelExtractor {
339
383
  standoffHeightMil: PcbEmbeddedModelExtractor.#parseMilLikeField(
340
384
  fields,
341
385
  'STANDOFFHEIGHT'
342
- )
386
+ ),
387
+ ...PcbEmbeddedModelExtractor.#shapeBodyMetadata(fields),
388
+ ...PcbEmbeddedModelExtractor.#stripUndefined(extra)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Normalizes shape-based body display metadata.
394
+ * @param {Record<string, string | string[]>} fields Native fields.
395
+ * @returns {object}
396
+ */
397
+ static #shapeBodyMetadata(fields) {
398
+ const modelType = PcbEmbeddedModelExtractor.#parseIntegerField(
399
+ fields,
400
+ 'MODEL.MODELTYPE'
401
+ )
402
+ const bodyColorRaw = PcbEmbeddedModelExtractor.#parseIntegerField(
403
+ fields,
404
+ 'BODYCOLOR3D'
405
+ )
406
+ const bodyOpacity = PcbEmbeddedModelExtractor.#parseNumberField(
407
+ fields,
408
+ 'BODYOPACITY3D'
409
+ )
410
+
411
+ return PcbEmbeddedModelExtractor.#stripUndefined({
412
+ modelType: modelType === null ? undefined : modelType,
413
+ modelTypeName:
414
+ modelType === null
415
+ ? undefined
416
+ : PcbEmbeddedModelExtractor.#modelTypeName(modelType),
417
+ bodyColor:
418
+ bodyColorRaw === null
419
+ ? undefined
420
+ : PcbEmbeddedModelExtractor.#decodeBodyColor(bodyColorRaw),
421
+ bodyOpacity:
422
+ bodyOpacity === null || !Number.isFinite(bodyOpacity)
423
+ ? undefined
424
+ : bodyOpacity
425
+ })
426
+ }
427
+
428
+ /**
429
+ * Maps a numeric body model type to a stable semantic label.
430
+ * @param {number} modelType Numeric model type.
431
+ * @returns {string}
432
+ */
433
+ static #modelTypeName(modelType) {
434
+ const modelTypeNames = new Map([
435
+ [0, 'extruded-polygon'],
436
+ [1, 'cone'],
437
+ [2, 'cylinder'],
438
+ [3, 'sphere']
439
+ ])
440
+
441
+ return modelTypeNames.get(modelType) || 'unknown-' + modelType
442
+ }
443
+
444
+ /**
445
+ * Decodes one packed native RGB color value.
446
+ * @param {number} rawColor Packed color value.
447
+ * @returns {{ raw: number, hex: string, rgb: { red: number, green: number, blue: number } }}
448
+ */
449
+ static #decodeBodyColor(rawColor) {
450
+ const red = rawColor & 0xff
451
+ const green = (rawColor >> 8) & 0xff
452
+ const blue = (rawColor >> 16) & 0xff
453
+
454
+ return {
455
+ raw: rawColor,
456
+ hex:
457
+ '#' +
458
+ [red, green, blue]
459
+ .map((component) => component.toString(16).padStart(2, '0'))
460
+ .join(''),
461
+ rgb: { red, green, blue }
343
462
  }
344
463
  }
345
464
 
@@ -359,8 +478,8 @@ export class PcbEmbeddedModelExtractor {
359
478
 
360
479
  /**
361
480
  * Deduplicates shape-based body records shared across body streams.
362
- * @param {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]} componentBodies
363
- * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
481
+ * @param {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object }[]} componentBodies
482
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null, modelType?: number, modelTypeName?: string, bodyColor?: object, bodyOpacity?: number, staticGeometry?: object }[]}
364
483
  */
365
484
  static #dedupeComponentBodies(componentBodies) {
366
485
  const uniqueBodies = new Map()
@@ -747,4 +866,15 @@ export class PcbEmbeddedModelExtractor {
747
866
  .filter(Number.isInteger)
748
867
  )
749
868
  }
869
+
870
+ /**
871
+ * Removes undefined values from a normalized metadata row.
872
+ * @param {object} row Metadata row.
873
+ * @returns {object}
874
+ */
875
+ static #stripUndefined(row) {
876
+ return Object.fromEntries(
877
+ Object.entries(row).filter(([, value]) => value !== undefined)
878
+ )
879
+ }
750
880
  }