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,503 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic netlist exports from normalized project bundles.
7
+ */
8
+ export class ProjectNetlistExporter {
9
+ /**
10
+ * Builds a line-oriented wirelist for CI and downstream tooling.
11
+ * @param {object} bundle Normalized design bundle or effective variant.
12
+ * @returns {string}
13
+ */
14
+ static buildWirelist(bundle) {
15
+ const netlist = ProjectNetlistExporter.buildNetlistJson(bundle)
16
+ const lines = [
17
+ '# altium-toolkit wirelist v1',
18
+ 'project ' + netlist.project
19
+ ]
20
+
21
+ for (const net of netlist.nets) {
22
+ lines.push('net ' + net.name)
23
+ for (const pin of net.pins) {
24
+ lines.push(' ' + pin.component + '.' + pin.pin)
25
+ }
26
+ }
27
+
28
+ lines.push('')
29
+ return lines.join('\n')
30
+ }
31
+
32
+ /**
33
+ * Builds a deterministic JSON netlist contract.
34
+ * @param {object} bundle Normalized design bundle or effective variant.
35
+ * @returns {{ schema: string, project: string, units: object, nets: object[] }}
36
+ */
37
+ static buildNetlistJson(bundle) {
38
+ const projectName =
39
+ bundle?.project?.name ||
40
+ bundle?.projectName ||
41
+ bundle?.name ||
42
+ bundle?.summary?.title ||
43
+ ''
44
+ const nets = (bundle?.nets || [])
45
+ .map((net) => ({
46
+ name: String(net?.name || ''),
47
+ aliases: ProjectNetlistExporter.#netAliases(net),
48
+ autoNamed: ProjectNetlistExporter.#isAutoNamedNet(net?.name),
49
+ signal: ProjectNetlistExporter.#signalDescriptor(net),
50
+ pins: ProjectNetlistExporter.#netPins(net),
51
+ sources: ProjectNetlistExporter.#netSources(net),
52
+ pcb: ProjectNetlistExporter.#pcbSources(net)
53
+ }))
54
+ .filter((net) => net.name)
55
+ .sort((left, right) =>
56
+ left.name.localeCompare(right.name, undefined, {
57
+ numeric: true
58
+ })
59
+ )
60
+
61
+ return {
62
+ schema: 'altium-toolkit.netlist.a1',
63
+ project: projectName,
64
+ units: {
65
+ coordinate: 'mil',
66
+ length: 'mil'
67
+ },
68
+ nets
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Extracts deterministic pins from one normalized net row.
74
+ * @param {object} net Net row.
75
+ * @returns {object[]}
76
+ */
77
+ static #netPins(net) {
78
+ const endpointsByPin = ProjectNetlistExporter.#pinEndpointMap(net)
79
+ const pins = (net?.pins || [])
80
+ .map((pin) =>
81
+ ProjectNetlistExporter.#pinDescriptor(pin, endpointsByPin)
82
+ )
83
+ .filter((pin) => pin.component && pin.pin)
84
+
85
+ return ProjectNetlistExporter.#dedupePins(pins).sort((left, right) => {
86
+ const componentOrder = left.component.localeCompare(
87
+ right.component,
88
+ undefined,
89
+ { numeric: true }
90
+ )
91
+ return (
92
+ componentOrder ||
93
+ left.pin.localeCompare(right.pin, undefined, { numeric: true })
94
+ )
95
+ })
96
+ }
97
+
98
+ /**
99
+ * Deduplicates pins while preserving first-seen data.
100
+ * @param {object[]} pins Candidate pins.
101
+ * @returns {object[]}
102
+ */
103
+ static #dedupePins(pins) {
104
+ const byKey = new Map()
105
+ for (const pin of pins || []) {
106
+ const key = pin.component + '\u0000' + pin.pin
107
+ if (!byKey.has(key)) {
108
+ byKey.set(key, pin)
109
+ continue
110
+ }
111
+
112
+ const existing = byKey.get(key)
113
+ existing.duplicateOccurrences ||= []
114
+ existing.duplicateOccurrences.push(
115
+ ProjectNetlistExporter.#duplicatePinDescriptor(pin)
116
+ )
117
+ }
118
+ return [...byKey.values()]
119
+ }
120
+
121
+ /**
122
+ * Builds one terminal descriptor from a normalized pin row.
123
+ * @param {object} pin Pin row.
124
+ * @param {Map<string, object[]>} endpointsByPin Endpoint lookup.
125
+ * @returns {object}
126
+ */
127
+ static #pinDescriptor(pin, endpointsByPin) {
128
+ const component = ProjectNetlistExporter.#pinComponent(pin)
129
+ const pinNumber = ProjectNetlistExporter.#pinNumber(pin)
130
+ const endpoints =
131
+ endpointsByPin.get(
132
+ ProjectNetlistExporter.#pinKey(component, pinNumber)
133
+ ) || []
134
+ const hierarchyPath = ProjectNetlistExporter.#dedupe(
135
+ endpoints.flatMap((endpoint) => endpoint.hierarchyPath || [])
136
+ )
137
+
138
+ return ProjectNetlistExporter.#stripEmpty({
139
+ component,
140
+ pin: pinNumber,
141
+ name: pin?.name && pin.name !== pinNumber ? String(pin.name) : '',
142
+ hierarchyPath,
143
+ ownerPartId: ProjectNetlistExporter.#ownerPartId(pin),
144
+ partUniqueId: ProjectNetlistExporter.#partUniqueId(pin),
145
+ isMultiPart: pin?.isMultiPart === true ? true : undefined,
146
+ alternatePartSuffix:
147
+ ProjectNetlistExporter.#alternatePartSuffix(pin),
148
+ endpoints: endpoints.map(
149
+ ({ hierarchyPath: _hierarchyPath, ...endpoint }) => endpoint
150
+ )
151
+ })
152
+ }
153
+
154
+ /**
155
+ * Builds compact duplicate-pin provenance.
156
+ * @param {object} pin Pin descriptor.
157
+ * @returns {object}
158
+ */
159
+ static #duplicatePinDescriptor(pin) {
160
+ return ProjectNetlistExporter.#stripEmpty({
161
+ component: pin.component,
162
+ pin: pin.pin,
163
+ name: pin.name,
164
+ ownerPartId: pin.ownerPartId,
165
+ partUniqueId: pin.partUniqueId,
166
+ isMultiPart: pin.isMultiPart,
167
+ alternatePartSuffix: pin.alternatePartSuffix
168
+ })
169
+ }
170
+
171
+ /**
172
+ * Builds schematic endpoint lookup rows for all pins in one net.
173
+ * @param {object} net Net row.
174
+ * @returns {Map<string, object[]>}
175
+ */
176
+ static #pinEndpointMap(net) {
177
+ const endpointsByPin = new Map()
178
+
179
+ for (const source of net?.schematic || []) {
180
+ const sheet = String(source?.fileName || '')
181
+ const hierarchyPath = ProjectNetlistExporter.#hierarchyPath(source)
182
+ for (const pin of source?.pins || []) {
183
+ const component = ProjectNetlistExporter.#pinComponent(pin)
184
+ const pinNumber = ProjectNetlistExporter.#pinNumber(pin)
185
+ if (!component || !pinNumber) {
186
+ continue
187
+ }
188
+
189
+ const key = ProjectNetlistExporter.#pinKey(component, pinNumber)
190
+ if (!endpointsByPin.has(key)) {
191
+ endpointsByPin.set(key, [])
192
+ }
193
+ endpointsByPin.get(key).push({
194
+ kind: 'schematic-pin',
195
+ key: sheet + ':pin:' + component + '.' + pinNumber,
196
+ sheet,
197
+ component,
198
+ pin: pinNumber,
199
+ hierarchyPath
200
+ })
201
+ }
202
+ }
203
+
204
+ return endpointsByPin
205
+ }
206
+
207
+ /**
208
+ * Builds graphical source groups for one net.
209
+ * @param {object} net Net row.
210
+ * @returns {object[]}
211
+ */
212
+ static #netSources(net) {
213
+ return (net?.schematic || []).map((source) => {
214
+ const sheet = String(source?.fileName || '')
215
+
216
+ return ProjectNetlistExporter.#stripEmpty({
217
+ sheet,
218
+ hierarchyPath: ProjectNetlistExporter.#hierarchyPath(source),
219
+ aliases: ProjectNetlistExporter.#sourceAliases(source),
220
+ graphicalElements: ProjectNetlistExporter.#graphicalElements(
221
+ source,
222
+ sheet
223
+ )
224
+ })
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Builds graphical element rows for one schematic net source.
230
+ * @param {object} source Schematic net source.
231
+ * @param {string} sheet Sheet file name.
232
+ * @returns {object[]}
233
+ */
234
+ static #graphicalElements(source, sheet) {
235
+ return [
236
+ ...(source?.segments || []).map((segment, index) =>
237
+ ProjectNetlistExporter.#stripEmpty({
238
+ kind: 'segment',
239
+ key: sheet + ':segment:' + index,
240
+ x1: segment.x1,
241
+ y1: segment.y1,
242
+ x2: segment.x2,
243
+ y2: segment.y2
244
+ })
245
+ ),
246
+ ...(source?.labels || []).map((label, index) =>
247
+ ProjectNetlistExporter.#stripEmpty({
248
+ kind: 'label',
249
+ key: sheet + ':label:' + index,
250
+ text: label.text,
251
+ x: label.x,
252
+ y: label.y
253
+ })
254
+ ),
255
+ ...(source?.ports || []).map((port, index) =>
256
+ ProjectNetlistExporter.#stripEmpty({
257
+ kind: 'port',
258
+ key: sheet + ':port:' + index,
259
+ text: port.name,
260
+ x: port.x,
261
+ y: port.y
262
+ })
263
+ ),
264
+ ...(source?.sheetEntries || []).map((entry, index) =>
265
+ ProjectNetlistExporter.#stripEmpty({
266
+ kind: 'sheet-entry',
267
+ key: sheet + ':sheet-entry:' + index,
268
+ text: entry.name,
269
+ x: entry.x,
270
+ y: entry.y
271
+ })
272
+ )
273
+ ]
274
+ }
275
+
276
+ /**
277
+ * Extracts PCB net provenance rows.
278
+ * @param {object} net Net row.
279
+ * @returns {object[]}
280
+ */
281
+ static #pcbSources(net) {
282
+ return (net?.pcb || []).map((entry) =>
283
+ ProjectNetlistExporter.#stripEmpty({
284
+ fileName: entry.fileName,
285
+ netIndex: entry.netIndex,
286
+ uniqueId: entry.uniqueId
287
+ })
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Collects known aliases for one net.
293
+ * @param {object} net Net row.
294
+ * @returns {string[]}
295
+ */
296
+ static #netAliases(net) {
297
+ return ProjectNetlistExporter.#dedupe(
298
+ (net?.schematic || []).flatMap((source) =>
299
+ ProjectNetlistExporter.#sourceAliases(source)
300
+ )
301
+ )
302
+ }
303
+
304
+ /**
305
+ * Collects aliases visible in one schematic net source.
306
+ * @param {object} source Schematic net source.
307
+ * @returns {string[]}
308
+ */
309
+ static #sourceAliases(source) {
310
+ return ProjectNetlistExporter.#dedupe([
311
+ ...(source?.labels || []).map((label) => label.text),
312
+ ...(source?.powerPorts || []).map((port) => port.text),
313
+ ...(source?.ports || []).map((port) => port.name),
314
+ ...(source?.sheetEntries || []).map((entry) => entry.name)
315
+ ])
316
+ }
317
+
318
+ /**
319
+ * Resolves a source hierarchy path.
320
+ * @param {object} source Schematic source row.
321
+ * @returns {string[]}
322
+ */
323
+ static #hierarchyPath(source) {
324
+ if (
325
+ Array.isArray(source?.hierarchyPath) &&
326
+ source.hierarchyPath.length
327
+ ) {
328
+ return source.hierarchyPath.map((part) => String(part))
329
+ }
330
+
331
+ return source?.fileName ? [String(source.fileName)] : []
332
+ }
333
+
334
+ /**
335
+ * Builds signal shape metadata for one net.
336
+ * @param {object} net Net row.
337
+ * @returns {{ type: string, baseName: string, suffix: string, sourceHints: string[] }}
338
+ */
339
+ static #signalDescriptor(net) {
340
+ const name = String(net?.name || '')
341
+ const bracket = name.match(/^(.+?)(\[[^\]]+\])$/u)
342
+ const sourceHints = ProjectNetlistExporter.#signalSourceHints(net)
343
+ const baseName = bracket ? bracket[1] : name
344
+ const suffix = bracket ? bracket[2] : ''
345
+ let type = 'normal'
346
+
347
+ if (sourceHints.includes('harness')) {
348
+ type = 'harness'
349
+ } else if (suffix && /(?:\.\.|:|,)/u.test(suffix)) {
350
+ type = 'wide'
351
+ } else if (suffix) {
352
+ type = 'sub'
353
+ } else if (sourceHints.includes('bus')) {
354
+ type = 'bus'
355
+ }
356
+
357
+ return {
358
+ type,
359
+ baseName,
360
+ suffix,
361
+ sourceHints
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Collects source-derived signal hints.
367
+ * @param {object} net Net row.
368
+ * @returns {string[]}
369
+ */
370
+ static #signalSourceHints(net) {
371
+ const hints = []
372
+ const schematicSources = net?.schematic || []
373
+ const hasHarness = schematicSources.some(
374
+ (source) =>
375
+ (source?.harnesses || []).length ||
376
+ (source?.sheetEntries || []).some(
377
+ (entry) => entry?.harnessType
378
+ ) ||
379
+ (source?.ports || []).some((port) => port?.harnessType)
380
+ )
381
+ const hasBus = schematicSources.some(
382
+ (source) =>
383
+ (source?.busEntries || []).length ||
384
+ (source?.segments || []).some(
385
+ (segment) => segment?.isBus === true
386
+ )
387
+ )
388
+
389
+ if (hasHarness) hints.push('harness')
390
+ if (hasBus) hints.push('bus')
391
+
392
+ return hints
393
+ }
394
+
395
+ /**
396
+ * Resolves native owner-part provenance from a pin row.
397
+ * @param {object} pin Pin row.
398
+ * @returns {string}
399
+ */
400
+ static #ownerPartId(pin) {
401
+ return String(
402
+ pin?.ownerPartId ||
403
+ pin?.ownerPartUniqueId ||
404
+ pin?.ownerPartUid ||
405
+ ''
406
+ ).trim()
407
+ }
408
+
409
+ /**
410
+ * Resolves the native part unique id from a pin row.
411
+ * @param {object} pin Pin row.
412
+ * @returns {string}
413
+ */
414
+ static #partUniqueId(pin) {
415
+ return String(pin?.partUniqueId || pin?.partUid || '').trim()
416
+ }
417
+
418
+ /**
419
+ * Returns an alternate-part suffix from a variant-qualified part id.
420
+ * @param {object} pin Pin row.
421
+ * @returns {string}
422
+ */
423
+ static #alternatePartSuffix(pin) {
424
+ const partUniqueId = ProjectNetlistExporter.#partUniqueId(pin)
425
+ return partUniqueId.includes('@')
426
+ ? partUniqueId.split('@').slice(1).join('@')
427
+ : ''
428
+ }
429
+
430
+ /**
431
+ * Resolves a component designator from a pin row.
432
+ * @param {object} pin Pin row.
433
+ * @returns {string}
434
+ */
435
+ static #pinComponent(pin) {
436
+ return String(pin?.componentDesignator || pin?.ownerIndex || '').trim()
437
+ }
438
+
439
+ /**
440
+ * Resolves a pin number from a pin row.
441
+ * @param {object} pin Pin row.
442
+ * @returns {string}
443
+ */
444
+ static #pinNumber(pin) {
445
+ return String(
446
+ pin?.pin || pin?.designator || pin?.number || pin?.name || ''
447
+ ).trim()
448
+ }
449
+
450
+ /**
451
+ * Builds a stable pin lookup key.
452
+ * @param {string} component Component designator.
453
+ * @param {string} pin Pin number.
454
+ * @returns {string}
455
+ */
456
+ static #pinKey(component, pin) {
457
+ return component + '\u0000' + pin
458
+ }
459
+
460
+ /**
461
+ * Returns true when a net name was synthesized.
462
+ * @param {unknown} name Net name.
463
+ * @returns {boolean}
464
+ */
465
+ static #isAutoNamedNet(name) {
466
+ return /^UnknownNet\d+$/u.test(String(name || ''))
467
+ }
468
+
469
+ /**
470
+ * Deduplicates non-empty strings.
471
+ * @param {unknown[]} values Candidate values.
472
+ * @returns {string[]}
473
+ */
474
+ static #dedupe(values) {
475
+ return [
476
+ ...new Set(
477
+ (values || [])
478
+ .map((value) => String(value || '').trim())
479
+ .filter(Boolean)
480
+ )
481
+ ]
482
+ }
483
+
484
+ /**
485
+ * Drops empty object fields while preserving zero and false.
486
+ * @param {Record<string, unknown>} value Candidate object.
487
+ * @returns {Record<string, unknown>}
488
+ */
489
+ static #stripEmpty(value) {
490
+ return Object.fromEntries(
491
+ Object.entries(value || {}).filter(([, entryValue]) => {
492
+ if (Array.isArray(entryValue)) {
493
+ return entryValue.length > 0
494
+ }
495
+ return (
496
+ entryValue !== null &&
497
+ entryValue !== undefined &&
498
+ entryValue !== ''
499
+ )
500
+ })
501
+ )
502
+ }
503
+ }
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds a read-only digest of project output-job documents and output rows.
7
+ */
8
+ export class ProjectOutJobDigestBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.project.outjob-digest.a1'
10
+
11
+ /**
12
+ * Builds an output-job digest from normalized project fragments.
13
+ * @param {{ documents?: object[], outputGroups?: object[] }} project Project fragments.
14
+ * @returns {object}
15
+ */
16
+ static build(project) {
17
+ const documents = (project?.documents || [])
18
+ .filter((document) => document.kind === 'output-job')
19
+ .map((document) => ({
20
+ documentIndex: document.index,
21
+ path: document.path,
22
+ normalizedPath: document.normalizedPath,
23
+ fileName: document.fileName
24
+ }))
25
+ const outputGroups = (project?.outputGroups || []).map((group) =>
26
+ ProjectOutJobDigestBuilder.#outputGroup(group)
27
+ )
28
+ const outputCount = outputGroups.reduce(
29
+ (sum, group) => sum + group.outputCount,
30
+ 0
31
+ )
32
+
33
+ return {
34
+ schema: ProjectOutJobDigestBuilder.SCHEMA_ID,
35
+ summary: {
36
+ outJobDocumentCount: documents.length,
37
+ outputGroupCount: outputGroups.length,
38
+ outputCount
39
+ },
40
+ documents,
41
+ outputGroups,
42
+ outputsByDocumentPath:
43
+ ProjectOutJobDigestBuilder.#outputsByDocumentPath(outputGroups)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Normalizes one output group.
49
+ * @param {object} group Project output group.
50
+ * @returns {object}
51
+ */
52
+ static #outputGroup(group) {
53
+ const outputs = (group.outputs || []).map((output) => ({
54
+ index: output.index,
55
+ type: output.type,
56
+ name: output.name,
57
+ documentPath: output.documentPath,
58
+ normalizedDocumentPath: ProjectOutJobDigestBuilder.#normalizePath(
59
+ output.documentPath
60
+ ),
61
+ variantName: output.variantName,
62
+ isDefault: output.isDefault
63
+ }))
64
+
65
+ return {
66
+ index: group.index,
67
+ name: group.name,
68
+ outputCount: outputs.length,
69
+ outputs
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Builds a lookup keyed by normalized output document path.
75
+ * @param {object[]} outputGroups Normalized output groups.
76
+ * @returns {Record<string, object[]>}
77
+ */
78
+ static #outputsByDocumentPath(outputGroups) {
79
+ const byPath = {}
80
+
81
+ for (const group of outputGroups) {
82
+ for (const output of group.outputs) {
83
+ const path = output.normalizedDocumentPath
84
+ if (!path) continue
85
+ byPath[path] ||= []
86
+ byPath[path].push({
87
+ outputGroupName: group.name,
88
+ outputGroupIndex: group.index,
89
+ outputIndex: output.index,
90
+ type: output.type,
91
+ name: output.name,
92
+ variantName: output.variantName,
93
+ isDefault: output.isDefault
94
+ })
95
+ }
96
+ }
97
+
98
+ return byPath
99
+ }
100
+
101
+ /**
102
+ * Normalizes project path separators.
103
+ * @param {string} path Raw path.
104
+ * @returns {string}
105
+ */
106
+ static #normalizePath(path) {
107
+ return String(path || '').replace(/\\/g, '/')
108
+ }
109
+ }