altium-toolkit 1.1.22 → 1.1.23

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 (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +2 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,205 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Provides deterministic conversions for common Altium length units.
7
+ */
8
+ export class AltiumUnits {
9
+ static RAW_UNITS_PER_MIL = 10000
10
+
11
+ static MM_PER_MIL = 0.0254
12
+
13
+ static MIL_PER_INCH = 1000
14
+
15
+ /**
16
+ * Converts millimeters to mils.
17
+ * @param {number} value Length in millimeters.
18
+ * @returns {number}
19
+ */
20
+ static mmToMil(value) {
21
+ return AltiumUnits.#round(Number(value) / AltiumUnits.MM_PER_MIL)
22
+ }
23
+
24
+ /**
25
+ * Converts mils to millimeters.
26
+ * @param {number} value Length in mils.
27
+ * @returns {number}
28
+ */
29
+ static milToMm(value) {
30
+ return AltiumUnits.#round(Number(value) * AltiumUnits.MM_PER_MIL)
31
+ }
32
+
33
+ /**
34
+ * Converts inches to mils.
35
+ * @param {number} value Length in inches.
36
+ * @returns {number}
37
+ */
38
+ static inchToMil(value) {
39
+ return AltiumUnits.#round(Number(value) * AltiumUnits.MIL_PER_INCH)
40
+ }
41
+
42
+ /**
43
+ * Converts mils to inches.
44
+ * @param {number} value Length in mils.
45
+ * @returns {number}
46
+ */
47
+ static milToInch(value) {
48
+ return AltiumUnits.#round(Number(value) / AltiumUnits.MIL_PER_INCH)
49
+ }
50
+
51
+ /**
52
+ * Converts raw Altium coordinate units to mils.
53
+ * @param {number} value Raw coordinate units.
54
+ * @returns {number}
55
+ */
56
+ static rawToMil(value) {
57
+ return AltiumUnits.#round(Number(value) / AltiumUnits.RAW_UNITS_PER_MIL)
58
+ }
59
+
60
+ /**
61
+ * Converts mils to raw Altium coordinate units.
62
+ * @param {number} value Length in mils.
63
+ * @returns {number}
64
+ */
65
+ static milToRaw(value) {
66
+ return AltiumUnits.#round(Number(value) * AltiumUnits.RAW_UNITS_PER_MIL)
67
+ }
68
+
69
+ /**
70
+ * Parses one numeric or textual length into a deterministic unit bundle.
71
+ * @param {unknown} value Length value.
72
+ * @param {{ defaultUnit?: 'mil' | 'mm' | 'in' | 'raw' }} [options]
73
+ * @returns {{ value: number, unit: string, mil: number, mm: number, inch: number, raw: number } | null}
74
+ */
75
+ static parseLength(value, options = {}) {
76
+ const parsed = AltiumUnits.#parseNumericAndUnit(value, options)
77
+ if (!parsed) return null
78
+
79
+ const mil = AltiumUnits.#toMil(parsed.value, parsed.unit)
80
+ if (!Number.isFinite(mil)) return null
81
+
82
+ return {
83
+ value: AltiumUnits.#round(parsed.value),
84
+ unit: parsed.unit,
85
+ mil: AltiumUnits.#round(mil),
86
+ mm: AltiumUnits.milToMm(mil),
87
+ inch: AltiumUnits.milToInch(mil),
88
+ raw: AltiumUnits.milToRaw(mil)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Formats a mil value in the requested target unit.
94
+ * @param {number} value Length in mils.
95
+ * @param {'mil' | 'mm' | 'in' | 'raw'} [unit]
96
+ * @returns {string}
97
+ */
98
+ static formatMil(value, unit = 'mil') {
99
+ const mil = Number(value)
100
+ if (!Number.isFinite(mil)) return ''
101
+
102
+ const normalizedUnit = AltiumUnits.#normalizeUnit(unit)
103
+ if (normalizedUnit === 'mm') {
104
+ return AltiumUnits.#formatNumber(AltiumUnits.milToMm(mil)) + 'mm'
105
+ }
106
+ if (normalizedUnit === 'in') {
107
+ return AltiumUnits.#formatNumber(AltiumUnits.milToInch(mil)) + 'in'
108
+ }
109
+ if (normalizedUnit === 'raw') {
110
+ return AltiumUnits.#formatNumber(AltiumUnits.milToRaw(mil)) + 'raw'
111
+ }
112
+ return AltiumUnits.#formatNumber(mil) + 'mil'
113
+ }
114
+
115
+ /**
116
+ * Parses an input value into a number and normalized unit token.
117
+ * @param {unknown} value Length input.
118
+ * @param {{ defaultUnit?: string }} options Parse options.
119
+ * @returns {{ value: number, unit: 'mil' | 'mm' | 'in' | 'raw' } | null}
120
+ */
121
+ static #parseNumericAndUnit(value, options) {
122
+ const defaultUnit = AltiumUnits.#normalizeUnit(
123
+ options?.defaultUnit || 'mil'
124
+ )
125
+
126
+ if (typeof value === 'number') {
127
+ return Number.isFinite(value) ? { value, unit: defaultUnit } : null
128
+ }
129
+
130
+ const text = String(value ?? '').trim()
131
+ const match = text.match(
132
+ /^([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?)\s*([A-Za-z]*)$/u
133
+ )
134
+ if (!match) return null
135
+
136
+ const numericValue = Number(match[1])
137
+ if (!Number.isFinite(numericValue)) return null
138
+
139
+ return {
140
+ value: numericValue,
141
+ unit: AltiumUnits.#normalizeUnit(match[2] || defaultUnit)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Converts one normalized length value to mils.
147
+ * @param {number} value Numeric length.
148
+ * @param {'mil' | 'mm' | 'in' | 'raw'} unit Length unit.
149
+ * @returns {number}
150
+ */
151
+ static #toMil(value, unit) {
152
+ if (unit === 'mm') return AltiumUnits.mmToMil(value)
153
+ if (unit === 'in') return AltiumUnits.inchToMil(value)
154
+ if (unit === 'raw') return AltiumUnits.rawToMil(value)
155
+ return Number(value)
156
+ }
157
+
158
+ /**
159
+ * Normalizes one unit label.
160
+ * @param {unknown} unit Unit label.
161
+ * @returns {'mil' | 'mm' | 'in' | 'raw'}
162
+ */
163
+ static #normalizeUnit(unit) {
164
+ const normalized = String(unit || '')
165
+ .trim()
166
+ .toLowerCase()
167
+ if (
168
+ normalized === 'mm' ||
169
+ normalized === 'millimeter' ||
170
+ normalized === 'millimeters'
171
+ ) {
172
+ return 'mm'
173
+ }
174
+ if (
175
+ normalized === 'in' ||
176
+ normalized === 'inch' ||
177
+ normalized === 'inches'
178
+ ) {
179
+ return 'in'
180
+ }
181
+ if (normalized === 'raw') {
182
+ return 'raw'
183
+ }
184
+ return 'mil'
185
+ }
186
+
187
+ /**
188
+ * Formats a number without unstable floating-point tails.
189
+ * @param {number} value Numeric value.
190
+ * @returns {string}
191
+ */
192
+ static #formatNumber(value) {
193
+ return String(AltiumUnits.#round(value))
194
+ }
195
+
196
+ /**
197
+ * Rounds a numeric value to stable JSON/report precision.
198
+ * @param {number} value Numeric value.
199
+ * @returns {number}
200
+ */
201
+ static #round(value) {
202
+ const numeric = Number(value)
203
+ return Number.isFinite(numeric) ? Number(numeric.toFixed(9)) : numeric
204
+ }
205
+ }
@@ -63,6 +63,15 @@ export class AsciiRecordParser {
63
63
  return records
64
64
  }
65
65
 
66
+ /**
67
+ * Parses one pipe-delimited record string into a field object.
68
+ * @param {string} raw Raw record text.
69
+ * @returns {{ raw: string, fields: Record<string, string | string[]> }}
70
+ */
71
+ static parseRecord(raw) {
72
+ return AsciiRecordParser.#parseRecord(raw)
73
+ }
74
+
66
75
  /**
67
76
  * Returns true when a printable run looks like an Altium record block.
68
77
  * @param {string} candidate
@@ -0,0 +1,383 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic inventories of embedded assets across parser roots.
7
+ */
8
+ export class EmbeddedAssetReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.embedded-assets.a1'
10
+
11
+ /**
12
+ * Builds an embedded-asset report.
13
+ * @param {{ models?: object[] } | object[]} input Parser-root models.
14
+ * @returns {object}
15
+ */
16
+ static build(input = {}) {
17
+ const models = EmbeddedAssetReportBuilder.#models(input)
18
+ const assets = EmbeddedAssetReportBuilder.#dedupeAssets(
19
+ models.flatMap((model) =>
20
+ EmbeddedAssetReportBuilder.#assetsForModel(model)
21
+ )
22
+ ).sort(EmbeddedAssetReportBuilder.#compareAssets)
23
+
24
+ return {
25
+ schema: EmbeddedAssetReportBuilder.SCHEMA,
26
+ summary: {
27
+ modelCount: models.length,
28
+ assetCount: assets.length,
29
+ totalByteCount: assets.reduce(
30
+ (count, asset) => count + asset.byteLength,
31
+ 0
32
+ ),
33
+ byKind: EmbeddedAssetReportBuilder.#byKind(assets)
34
+ },
35
+ assets
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Normalizes builder input to a model array.
41
+ * @param {{ models?: object[] } | object[]} input Builder input.
42
+ * @returns {object[]}
43
+ */
44
+ static #models(input) {
45
+ if (Array.isArray(input)) return input
46
+ return Array.isArray(input?.models) ? input.models : []
47
+ }
48
+
49
+ /**
50
+ * Collects embedded asset rows from one parser-root model.
51
+ * @param {object} model Parser-root model.
52
+ * @returns {object[]}
53
+ */
54
+ static #assetsForModel(model) {
55
+ const base = {
56
+ modelFileName: String(model?.fileName || ''),
57
+ modelKind: String(model?.kind || '')
58
+ }
59
+
60
+ return [
61
+ ...EmbeddedAssetReportBuilder.#schematicAssets(base, model),
62
+ ...EmbeddedAssetReportBuilder.#schematicLibraryAssets(base, model),
63
+ ...EmbeddedAssetReportBuilder.#pcbAssets(base, model),
64
+ ...EmbeddedAssetReportBuilder.#pcbLibraryAssets(base, model),
65
+ ...EmbeddedAssetReportBuilder.#integratedLibraryAssets(base, model)
66
+ ].filter(Boolean)
67
+ }
68
+
69
+ /**
70
+ * Collects schematic-document assets.
71
+ * @param {object} base Shared row fields.
72
+ * @param {object} model Parser-root model.
73
+ * @returns {object[]}
74
+ */
75
+ static #schematicAssets(base, model) {
76
+ const schematic = model?.schematic || {}
77
+
78
+ return [
79
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
80
+ base,
81
+ 'embedded-file',
82
+ schematic.embeddedFiles?.files || []
83
+ ),
84
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
85
+ base,
86
+ 'schematic-image',
87
+ schematic.images || [],
88
+ { fallbackName: 'image' }
89
+ ),
90
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
91
+ base,
92
+ 'schematic-thumbnail',
93
+ schematic.thumbnails || [],
94
+ { fallbackName: 'thumbnail' }
95
+ )
96
+ ]
97
+ }
98
+
99
+ /**
100
+ * Collects schematic-library assets.
101
+ * @param {object} base Shared row fields.
102
+ * @param {object} model Parser-root model.
103
+ * @returns {object[]}
104
+ */
105
+ static #schematicLibraryAssets(base, model) {
106
+ const schematicLibrary = model?.schematicLibrary || {}
107
+ const embeddedFiles = schematicLibrary.embeddedFiles?.files || []
108
+ const symbolAssets = (schematicLibrary.symbols || []).flatMap(
109
+ (symbol) =>
110
+ (symbol.embeddedAssets || []).map((asset) => ({
111
+ ...asset,
112
+ symbolName: symbol.name
113
+ }))
114
+ )
115
+
116
+ return EmbeddedAssetReportBuilder.#collectionAssets(
117
+ base,
118
+ 'embedded-file',
119
+ embeddedFiles.length ? embeddedFiles : symbolAssets
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Collects PCB-document assets.
125
+ * @param {object} base Shared row fields.
126
+ * @param {object} model Parser-root model.
127
+ * @returns {object[]}
128
+ */
129
+ static #pcbAssets(base, model) {
130
+ const pcb = model?.pcb || {}
131
+
132
+ return [
133
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
134
+ base,
135
+ 'pcb-font',
136
+ pcb.embeddedFonts || []
137
+ ),
138
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
139
+ base,
140
+ 'pcb-model',
141
+ pcb.embeddedModels || []
142
+ )
143
+ ]
144
+ }
145
+
146
+ /**
147
+ * Collects PCB-library assets.
148
+ * @param {object} base Shared row fields.
149
+ * @param {object} model Parser-root model.
150
+ * @returns {object[]}
151
+ */
152
+ static #pcbLibraryAssets(base, model) {
153
+ const pcbLibrary = model?.pcbLibrary || {}
154
+ const footprintModels = (pcbLibrary.footprints || []).flatMap(
155
+ (footprint) =>
156
+ (footprint.embeddedModels || []).map((asset) => ({
157
+ ...asset,
158
+ footprintName: footprint.name
159
+ }))
160
+ )
161
+
162
+ return [
163
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
164
+ base,
165
+ 'pcb-font',
166
+ pcbLibrary.embeddedFonts || []
167
+ ),
168
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
169
+ base,
170
+ 'pcb-model',
171
+ pcbLibrary.embeddedModels || []
172
+ ),
173
+ ...EmbeddedAssetReportBuilder.#collectionAssets(
174
+ base,
175
+ 'pcb-model',
176
+ footprintModels
177
+ )
178
+ ]
179
+ }
180
+
181
+ /**
182
+ * Collects integrated-library source assets.
183
+ * @param {object} base Shared row fields.
184
+ * @param {object} model Parser-root model.
185
+ * @returns {object[]}
186
+ */
187
+ static #integratedLibraryAssets(base, model) {
188
+ return EmbeddedAssetReportBuilder.#collectionAssets(
189
+ base,
190
+ 'integrated-library-source',
191
+ model?.integratedLibrary?.sources || []
192
+ )
193
+ }
194
+
195
+ /**
196
+ * Normalizes one asset collection.
197
+ * @param {object} base Shared row fields.
198
+ * @param {string} kind Asset kind.
199
+ * @param {object[]} assets Source assets.
200
+ * @param {{ fallbackName?: string }} options Collection options.
201
+ * @returns {object[]}
202
+ */
203
+ static #collectionAssets(base, kind, assets, options = {}) {
204
+ return (Array.isArray(assets) ? assets : []).map((asset, index) =>
205
+ EmbeddedAssetReportBuilder.#assetRow(base, kind, asset, index, {
206
+ fallbackName: options.fallbackName || kind
207
+ })
208
+ )
209
+ }
210
+
211
+ /**
212
+ * Normalizes one asset row.
213
+ * @param {object} base Shared row fields.
214
+ * @param {string} kind Asset kind.
215
+ * @param {object} asset Source asset.
216
+ * @param {number} index Collection index.
217
+ * @param {{ fallbackName: string }} options Row options.
218
+ * @returns {object}
219
+ */
220
+ static #assetRow(base, kind, asset, index, options) {
221
+ return EmbeddedAssetReportBuilder.#stripUndefined({
222
+ ...base,
223
+ kind,
224
+ name: EmbeddedAssetReportBuilder.#assetName(
225
+ asset,
226
+ index,
227
+ options.fallbackName
228
+ ),
229
+ format: EmbeddedAssetReportBuilder.#format(asset),
230
+ sourceStream: EmbeddedAssetReportBuilder.#sourceStream(asset),
231
+ byteLength: EmbeddedAssetReportBuilder.#byteLength(asset),
232
+ symbolName: asset?.symbolName,
233
+ footprintName: asset?.footprintName
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Resolves a stable asset name.
239
+ * @param {object} asset Source asset.
240
+ * @param {number} index Collection index.
241
+ * @param {string} fallbackName Fallback prefix.
242
+ * @returns {string}
243
+ */
244
+ static #assetName(asset, index, fallbackName) {
245
+ return String(
246
+ asset?.name ||
247
+ asset?.family ||
248
+ asset?.fileName ||
249
+ asset?.key ||
250
+ asset?.id ||
251
+ EmbeddedAssetReportBuilder.#basename(
252
+ EmbeddedAssetReportBuilder.#sourceStream(asset)
253
+ ) ||
254
+ fallbackName + '-' + index
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Resolves a source stream or source path.
260
+ * @param {object} asset Source asset.
261
+ * @returns {string}
262
+ */
263
+ static #sourceStream(asset) {
264
+ return String(asset?.sourceStream || asset?.path || asset?.stream || '')
265
+ }
266
+
267
+ /**
268
+ * Resolves asset byte length.
269
+ * @param {object} asset Source asset.
270
+ * @returns {number}
271
+ */
272
+ static #byteLength(asset) {
273
+ const value =
274
+ asset?.byteLength ??
275
+ asset?.byteCount ??
276
+ asset?.payloadByteLength ??
277
+ asset?.size ??
278
+ 0
279
+ const numeric = Number(value)
280
+ return Number.isFinite(numeric) ? numeric : 0
281
+ }
282
+
283
+ /**
284
+ * Resolves a compact asset format.
285
+ * @param {object} asset Source asset.
286
+ * @returns {string}
287
+ */
288
+ static #format(asset) {
289
+ const explicit =
290
+ asset?.format || asset?.fileType || asset?.type || asset?.mimeType
291
+ const normalized = String(explicit || '').trim()
292
+
293
+ if (!normalized) return 'unknown'
294
+ if (normalized.includes('/')) {
295
+ return normalized.split('/').pop()
296
+ }
297
+
298
+ return normalized
299
+ }
300
+
301
+ /**
302
+ * Builds a sorted count map by asset kind.
303
+ * @param {object[]} assets Asset rows.
304
+ * @returns {Record<string, number>}
305
+ */
306
+ static #byKind(assets) {
307
+ const counts = new Map()
308
+
309
+ for (const asset of assets) {
310
+ counts.set(asset.kind, (counts.get(asset.kind) || 0) + 1)
311
+ }
312
+
313
+ return Object.fromEntries(
314
+ [...counts.entries()].sort(([left], [right]) =>
315
+ left.localeCompare(right)
316
+ )
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Dedupe exact asset references.
322
+ * @param {object[]} assets Asset rows.
323
+ * @returns {object[]}
324
+ */
325
+ static #dedupeAssets(assets) {
326
+ const byKey = new Map()
327
+
328
+ for (const asset of assets) {
329
+ const key = [
330
+ asset.modelFileName,
331
+ asset.modelKind,
332
+ asset.kind,
333
+ asset.sourceStream,
334
+ asset.name
335
+ ].join('\0')
336
+ if (!byKey.has(key)) {
337
+ byKey.set(key, asset)
338
+ }
339
+ }
340
+
341
+ return [...byKey.values()]
342
+ }
343
+
344
+ /**
345
+ * Sorts report rows deterministically.
346
+ * @param {object} left Left row.
347
+ * @param {object} right Right row.
348
+ * @returns {number}
349
+ */
350
+ static #compareAssets(left, right) {
351
+ return (
352
+ left.modelFileName.localeCompare(right.modelFileName) ||
353
+ left.kind.localeCompare(right.kind) ||
354
+ left.name.localeCompare(right.name) ||
355
+ left.sourceStream.localeCompare(right.sourceStream)
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Resolves a path basename.
361
+ * @param {string} value Path-like value.
362
+ * @returns {string}
363
+ */
364
+ static #basename(value) {
365
+ return (
366
+ String(value || '')
367
+ .split('/')
368
+ .filter(Boolean)
369
+ .pop() || ''
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Removes undefined values from a row.
375
+ * @param {object} row Row to normalize.
376
+ * @returns {object}
377
+ */
378
+ static #stripUndefined(row) {
379
+ return Object.fromEntries(
380
+ Object.entries(row).filter(([, value]) => value !== undefined)
381
+ )
382
+ }
383
+ }