altium-toolkit 1.0.10 → 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 (63) 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 +4 -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 +132 -1
  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/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,504 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic QA reports across parsed schematic and PCB libraries.
7
+ */
8
+ export class LibraryQaReportBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.library.qa.a1'
10
+
11
+ /**
12
+ * Builds a read-only library QA report.
13
+ * @param {{ schematicLibraries?: object[], pcbLibraries?: object[] }} options Library collections.
14
+ * @returns {object}
15
+ */
16
+ static build(options = {}) {
17
+ const schematicLibraries = options.schematicLibraries || []
18
+ const pcbLibraries = options.pcbLibraries || []
19
+ const duplicateSymbols =
20
+ LibraryQaReportBuilder.#duplicateSymbols(schematicLibraries)
21
+ const duplicateFootprints =
22
+ LibraryQaReportBuilder.#duplicateFootprints(pcbLibraries)
23
+ const staleImplementations =
24
+ LibraryQaReportBuilder.#staleImplementations(
25
+ schematicLibraries,
26
+ pcbLibraries
27
+ )
28
+ const missingModels =
29
+ LibraryQaReportBuilder.#missingModels(pcbLibraries)
30
+ const multipartMismatches =
31
+ LibraryQaReportBuilder.#multipartMismatches(schematicLibraries)
32
+ const mergePlan =
33
+ LibraryQaReportBuilder.#schematicLibraryMergePlan(
34
+ schematicLibraries
35
+ )
36
+ const issues = [
37
+ ...duplicateSymbols.map((issue) =>
38
+ LibraryQaReportBuilder.#issue(
39
+ 'library.duplicate-symbol',
40
+ issue.name
41
+ )
42
+ ),
43
+ ...duplicateFootprints.map((issue) =>
44
+ LibraryQaReportBuilder.#issue(
45
+ 'library.duplicate-footprint',
46
+ issue.name
47
+ )
48
+ ),
49
+ ...staleImplementations.map((issue) =>
50
+ LibraryQaReportBuilder.#issue(
51
+ 'library.stale-implementation',
52
+ issue.symbolName
53
+ )
54
+ ),
55
+ ...missingModels.map((issue) =>
56
+ LibraryQaReportBuilder.#issue(
57
+ 'library.missing-model',
58
+ issue.footprintName
59
+ )
60
+ ),
61
+ ...multipartMismatches.map((issue) =>
62
+ LibraryQaReportBuilder.#issue(
63
+ 'library.multipart-mismatch',
64
+ issue.symbolName
65
+ )
66
+ ),
67
+ ...mergePlan.diagnostics.map((diagnostic) =>
68
+ LibraryQaReportBuilder.#issue(
69
+ diagnostic.code,
70
+ diagnostic.symbolName
71
+ )
72
+ )
73
+ ]
74
+
75
+ return {
76
+ schema: LibraryQaReportBuilder.SCHEMA_ID,
77
+ summary: {
78
+ schematicLibraryCount: schematicLibraries.length,
79
+ pcbLibraryCount: pcbLibraries.length,
80
+ duplicateSymbolCount: duplicateSymbols.length,
81
+ duplicateFootprintCount: duplicateFootprints.length,
82
+ staleImplementationCount: staleImplementations.length,
83
+ missingModelCount: missingModels.length,
84
+ multipartMismatchCount: multipartMismatches.length,
85
+ mergePlanConflictCount: mergePlan.summary.conflictCount,
86
+ issueCount: issues.length
87
+ },
88
+ duplicates: {
89
+ symbols: duplicateSymbols,
90
+ footprints: duplicateFootprints
91
+ },
92
+ staleImplementations,
93
+ missingModels,
94
+ multipartMismatches,
95
+ mergePlan,
96
+ issues
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Finds duplicate schematic symbols by name.
102
+ * @param {object[]} libraries Schematic library models.
103
+ * @returns {object[]}
104
+ */
105
+ static #duplicateSymbols(libraries) {
106
+ const byName = new Map()
107
+
108
+ for (const library of libraries || []) {
109
+ const fileName = library.fileName || ''
110
+ for (const [index, symbol] of (
111
+ library.schematicLibrary?.symbols || []
112
+ ).entries()) {
113
+ const name = String(symbol.name || '').trim()
114
+ if (!name) continue
115
+ byName.set(name, [
116
+ ...(byName.get(name) || []),
117
+ { libraryFileName: fileName, index }
118
+ ])
119
+ }
120
+ }
121
+
122
+ return [...byName.entries()]
123
+ .filter(([, occurrences]) => occurrences.length > 1)
124
+ .map(([name, occurrences]) => ({ name, occurrences }))
125
+ .sort((left, right) => left.name.localeCompare(right.name))
126
+ }
127
+
128
+ /**
129
+ * Finds duplicate PCB footprints and classifies shape collisions.
130
+ * @param {object[]} libraries PCB library models.
131
+ * @returns {object[]}
132
+ */
133
+ static #duplicateFootprints(libraries) {
134
+ const byName = new Map()
135
+
136
+ for (const library of libraries || []) {
137
+ const fileName = library.fileName || ''
138
+ for (const [index, footprint] of (
139
+ library.pcbLibrary?.footprints || []
140
+ ).entries()) {
141
+ const name = String(footprint.name || '').trim()
142
+ if (!name) continue
143
+ byName.set(name, [
144
+ ...(byName.get(name) || []),
145
+ {
146
+ libraryFileName: fileName,
147
+ index,
148
+ padCount: (footprint.pads || []).length
149
+ }
150
+ ])
151
+ }
152
+ }
153
+
154
+ return [...byName.entries()]
155
+ .filter(([, occurrences]) => occurrences.length > 1)
156
+ .map(([name, occurrences]) => ({
157
+ name,
158
+ occurrences,
159
+ collisionKind:
160
+ LibraryQaReportBuilder.#footprintCollisionKind(occurrences)
161
+ }))
162
+ .sort((left, right) => left.name.localeCompare(right.name))
163
+ }
164
+
165
+ /**
166
+ * Finds implementation rows that target absent PCB library files.
167
+ * @param {object[]} schematicLibraries Schematic libraries.
168
+ * @param {object[]} pcbLibraries PCB libraries.
169
+ * @returns {object[]}
170
+ */
171
+ static #staleImplementations(schematicLibraries, pcbLibraries) {
172
+ const availablePcbLibraries = new Set(
173
+ (pcbLibraries || []).map((library) => library.fileName || '')
174
+ )
175
+ const issues = []
176
+
177
+ for (const library of schematicLibraries || []) {
178
+ for (const symbol of library.schematicLibrary?.symbols || []) {
179
+ for (const implementation of symbol.implementations || []) {
180
+ const targetLibraries = implementation.targetLibraries || []
181
+ const hasMissingTarget = targetLibraries.some(
182
+ (target) => !availablePcbLibraries.has(target)
183
+ )
184
+ if (!hasMissingTarget) continue
185
+ issues.push({
186
+ libraryFileName: library.fileName || '',
187
+ symbolName: symbol.name || '',
188
+ modelName: implementation.modelName || '',
189
+ targetLibraries,
190
+ reason: 'target library was not present in the scanned collection'
191
+ })
192
+ }
193
+ }
194
+ }
195
+
196
+ return issues
197
+ }
198
+
199
+ /**
200
+ * Finds footprint component bodies that reference missing embedded models.
201
+ * @param {object[]} pcbLibraries PCB libraries.
202
+ * @returns {object[]}
203
+ */
204
+ static #missingModels(pcbLibraries) {
205
+ const issues = []
206
+
207
+ for (const library of pcbLibraries || []) {
208
+ for (const footprint of library.pcbLibrary?.footprints || []) {
209
+ const modelIds = new Set(
210
+ (footprint.embeddedModels || []).map((model) =>
211
+ String(model.id || model.modelId || '')
212
+ )
213
+ )
214
+ for (const body of footprint.componentBodies || []) {
215
+ const modelId = String(body.modelId || body.id || '')
216
+ if (!modelId || modelIds.has(modelId)) continue
217
+ issues.push({
218
+ libraryFileName: library.fileName || '',
219
+ footprintName: footprint.name || '',
220
+ modelId,
221
+ reason: 'component body references an embedded model that is absent'
222
+ })
223
+ }
224
+ }
225
+ }
226
+
227
+ return issues
228
+ }
229
+
230
+ /**
231
+ * Finds multipart symbols whose part ids skip expected alphabetical parts.
232
+ * @param {object[]} schematicLibraries Schematic libraries.
233
+ * @returns {object[]}
234
+ */
235
+ static #multipartMismatches(schematicLibraries) {
236
+ const issues = []
237
+
238
+ for (const library of schematicLibraries || []) {
239
+ for (const symbol of library.schematicLibrary?.symbols || []) {
240
+ const partIds = (symbol.parts || [])
241
+ .map((part) => String(part.partId || '').trim())
242
+ .filter(Boolean)
243
+ if (partIds.length < 2) continue
244
+ const expectedPartIds = LibraryQaReportBuilder.#expectedPartIds(
245
+ partIds.length
246
+ )
247
+ if (partIds.join('\u0000') === expectedPartIds.join('\u0000')) {
248
+ continue
249
+ }
250
+ issues.push({
251
+ libraryFileName: library.fileName || '',
252
+ symbolName: symbol.name || '',
253
+ partIds,
254
+ expectedPartIds
255
+ })
256
+ }
257
+ }
258
+
259
+ return issues
260
+ }
261
+
262
+ /**
263
+ * Builds a read-only merge plan for schematic libraries.
264
+ * @param {object[]} schematicLibraries Schematic library models.
265
+ * @returns {object}
266
+ */
267
+ static #schematicLibraryMergePlan(schematicLibraries) {
268
+ const duplicateSymbols =
269
+ LibraryQaReportBuilder.#mergePlanDuplicateSymbols(
270
+ schematicLibraries
271
+ )
272
+ const embeddedAssets =
273
+ LibraryQaReportBuilder.#mergePlanEmbeddedAssets(schematicLibraries)
274
+ const fontDependencies =
275
+ LibraryQaReportBuilder.#mergePlanFontDependencies(
276
+ schematicLibraries
277
+ )
278
+ const diagnostics = duplicateSymbols
279
+ .filter(
280
+ (duplicate) => duplicate.conflictKind === 'conflicting-symbol'
281
+ )
282
+ .map((duplicate) => ({
283
+ code: 'library.merge-plan.conflicting-symbol',
284
+ severity: 'warning',
285
+ symbolName: duplicate.name
286
+ }))
287
+
288
+ return {
289
+ schema: 'altium-toolkit.library.merge-plan.a1',
290
+ strategy: 'read-only-analysis',
291
+ summary: {
292
+ duplicateNameCount: duplicateSymbols.length,
293
+ conflictCount: diagnostics.length,
294
+ renameSuggestionCount: duplicateSymbols.reduce(
295
+ (count, duplicate) =>
296
+ count +
297
+ Math.max(duplicate.suggestedNames.length - 1, 0),
298
+ 0
299
+ ),
300
+ embeddedAssetCount: embeddedAssets.length,
301
+ fontDependencyCount: fontDependencies.length
302
+ },
303
+ duplicateSymbols,
304
+ embeddedAssets,
305
+ fontDependencies,
306
+ diagnostics
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Builds duplicate-symbol merge-plan rows.
312
+ * @param {object[]} schematicLibraries Schematic libraries.
313
+ * @returns {object[]}
314
+ */
315
+ static #mergePlanDuplicateSymbols(schematicLibraries) {
316
+ const byName = new Map()
317
+
318
+ for (const library of schematicLibraries || []) {
319
+ const fileName = library.fileName || ''
320
+ for (const [index, symbol] of (
321
+ library.schematicLibrary?.symbols || []
322
+ ).entries()) {
323
+ const name = String(symbol.name || '').trim()
324
+ if (!name) continue
325
+ byName.set(name, [
326
+ ...(byName.get(name) || []),
327
+ LibraryQaReportBuilder.#mergePlanSymbolOccurrence(
328
+ fileName,
329
+ index,
330
+ symbol
331
+ )
332
+ ])
333
+ }
334
+ }
335
+
336
+ return [...byName.entries()]
337
+ .filter(([, occurrences]) => occurrences.length > 1)
338
+ .map(([name, occurrences]) => {
339
+ const differences =
340
+ LibraryQaReportBuilder.#mergePlanDifferences(occurrences)
341
+ return {
342
+ name,
343
+ conflictKind: Object.keys(differences).length
344
+ ? 'conflicting-symbol'
345
+ : 'duplicate-name',
346
+ suggestedNames: occurrences.map((occurrence, index) => ({
347
+ libraryFileName: occurrence.libraryFileName,
348
+ index: occurrence.index,
349
+ currentName: name,
350
+ suggestedName:
351
+ index === 0 ? name : name + '_' + (index + 1)
352
+ })),
353
+ ...(Object.keys(differences).length ? { differences } : {}),
354
+ occurrences
355
+ }
356
+ })
357
+ .sort((left, right) => left.name.localeCompare(right.name))
358
+ }
359
+
360
+ /**
361
+ * Builds one duplicate-symbol occurrence summary.
362
+ * @param {string} libraryFileName Library file name.
363
+ * @param {number} index Symbol index.
364
+ * @param {object} symbol Symbol row.
365
+ * @returns {object}
366
+ */
367
+ static #mergePlanSymbolOccurrence(libraryFileName, index, symbol) {
368
+ return {
369
+ libraryFileName,
370
+ index,
371
+ pinCount: (symbol.pins || []).length,
372
+ partCount: (symbol.parts || []).length,
373
+ displayModeCount: (
374
+ symbol.displayModes ||
375
+ symbol.displayModeCatalog ||
376
+ []
377
+ ).length
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Builds differing-count metadata for duplicate symbols.
383
+ * @param {object[]} occurrences Duplicate occurrences.
384
+ * @returns {object}
385
+ */
386
+ static #mergePlanDifferences(occurrences) {
387
+ return LibraryQaReportBuilder.#stripEmpty({
388
+ pinCounts: LibraryQaReportBuilder.#differingCounts(
389
+ occurrences,
390
+ 'pinCount'
391
+ ),
392
+ partCounts: LibraryQaReportBuilder.#differingCounts(
393
+ occurrences,
394
+ 'partCount'
395
+ ),
396
+ displayModeCounts: LibraryQaReportBuilder.#differingCounts(
397
+ occurrences,
398
+ 'displayModeCount'
399
+ )
400
+ })
401
+ }
402
+
403
+ /**
404
+ * Returns differing values for one occurrence count key.
405
+ * @param {object[]} occurrences Occurrence rows.
406
+ * @param {string} key Count key.
407
+ * @returns {number[] | undefined}
408
+ */
409
+ static #differingCounts(occurrences, key) {
410
+ const values = (occurrences || []).map((occurrence) => occurrence[key])
411
+ return new Set(values).size > 1 ? values : undefined
412
+ }
413
+
414
+ /**
415
+ * Lists embedded assets referenced by schematic symbols.
416
+ * @param {object[]} schematicLibraries Schematic libraries.
417
+ * @returns {object[]}
418
+ */
419
+ static #mergePlanEmbeddedAssets(schematicLibraries) {
420
+ return (schematicLibraries || []).flatMap((library) =>
421
+ (library.schematicLibrary?.symbols || []).flatMap((symbol) =>
422
+ (symbol.embeddedAssets || symbol.images || []).map((asset) =>
423
+ LibraryQaReportBuilder.#stripEmpty({
424
+ libraryFileName: library.fileName || '',
425
+ symbolName: symbol.name || '',
426
+ ...asset
427
+ })
428
+ )
429
+ )
430
+ )
431
+ }
432
+
433
+ /**
434
+ * Lists schematic-library font dependencies.
435
+ * @param {object[]} schematicLibraries Schematic libraries.
436
+ * @returns {object[]}
437
+ */
438
+ static #mergePlanFontDependencies(schematicLibraries) {
439
+ return (schematicLibraries || []).flatMap((library) =>
440
+ (
441
+ library.schematicLibrary?.fonts ||
442
+ library.schematicLibrary?.embeddedFonts ||
443
+ []
444
+ ).map((font) =>
445
+ LibraryQaReportBuilder.#stripEmpty({
446
+ libraryFileName: library.fileName || '',
447
+ ...font
448
+ })
449
+ )
450
+ )
451
+ }
452
+
453
+ /**
454
+ * Classifies whether duplicate footprints appear equivalent.
455
+ * @param {{ padCount: number }[]} occurrences Footprint occurrences.
456
+ * @returns {string}
457
+ */
458
+ static #footprintCollisionKind(occurrences) {
459
+ const padCounts = new Set(
460
+ (occurrences || []).map((occurrence) => occurrence.padCount)
461
+ )
462
+
463
+ return padCounts.size > 1 ? 'conflicting-footprint' : 'duplicate-name'
464
+ }
465
+
466
+ /**
467
+ * Builds expected alphabetical part ids.
468
+ * @param {number} count Part count.
469
+ * @returns {string[]}
470
+ */
471
+ static #expectedPartIds(count) {
472
+ return Array.from({ length: count }, (_value, index) =>
473
+ String.fromCharCode(65 + index)
474
+ )
475
+ }
476
+
477
+ /**
478
+ * Builds a compact issue entry for summary consumers.
479
+ * @param {string} code Diagnostic code.
480
+ * @param {string} target Target object name.
481
+ * @returns {object}
482
+ */
483
+ static #issue(code, target) {
484
+ return {
485
+ code,
486
+ severity: 'warning',
487
+ target
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Removes undefined and empty-string fields.
493
+ * @param {Record<string, unknown>} value Source object.
494
+ * @returns {object}
495
+ */
496
+ static #stripEmpty(value) {
497
+ return Object.fromEntries(
498
+ Object.entries(value || {}).filter(
499
+ ([, entryValue]) =>
500
+ entryValue !== undefined && entryValue !== ''
501
+ )
502
+ )
503
+ }
504
+ }
@@ -86,6 +86,32 @@ export class LibraryRenderManifestBuilder {
86
86
  return {
87
87
  schema: 'altium-toolkit.schematic.extraction-manifest.a1',
88
88
  sourceDocument: String(documentModel?.fileName || ''),
89
+ summary: {
90
+ outputCount: outputs.length,
91
+ embeddedAssetCount:
92
+ LibraryRenderManifestBuilder.#dedupeEmbeddedAssets(
93
+ outputs.flatMap((output) => output.embeddedAssets || [])
94
+ ).length,
95
+ readyOutputCount: outputs.filter(
96
+ (output) =>
97
+ output.databaseLibrary?.readiness === 'ready' ||
98
+ !output.databaseLibrary
99
+ ).length,
100
+ strippedParameterCount: outputs.reduce(
101
+ (count, output) =>
102
+ count +
103
+ (output.databaseLibrary?.strippedParameterNames
104
+ ?.length || 0),
105
+ 0
106
+ ),
107
+ strippedImplementationCount: outputs.reduce(
108
+ (count, output) =>
109
+ count +
110
+ (output.databaseLibrary?.strippedImplementationKeys
111
+ ?.length || 0),
112
+ 0
113
+ )
114
+ },
89
115
  outputs,
90
116
  embeddedAssets: LibraryRenderManifestBuilder.#dedupeEmbeddedAssets(
91
117
  outputs.flatMap((output) => output.embeddedAssets || [])
@@ -93,6 +119,62 @@ export class LibraryRenderManifestBuilder {
93
119
  }
94
120
  }
95
121
 
122
+ /**
123
+ * Builds a read-only manifest for extracting a schematic template.
124
+ * @param {{ fileName?: string, schematic?: { template?: object } } | { template?: object }} documentModel Parsed schematic document model.
125
+ * @returns {object}
126
+ */
127
+ static buildSchematicTemplateExtractionManifest(documentModel) {
128
+ const template =
129
+ documentModel?.schematic?.template ||
130
+ documentModel?.template ||
131
+ null
132
+ const identity = template?.identity || {}
133
+ const outputKey =
134
+ 'schematic-template/' +
135
+ LibraryRenderManifestBuilder.#slug(
136
+ LibraryRenderManifestBuilder.#withoutExtension(
137
+ identity.fileName || identity.name || 'template'
138
+ )
139
+ ) +
140
+ '.schdot'
141
+ const diagnostics = (template?.missingParameters || []).map(
142
+ (parameterName) => ({
143
+ code: 'schematic.template-extraction.missing-parameter',
144
+ severity: 'warning',
145
+ parameterName
146
+ })
147
+ )
148
+
149
+ return {
150
+ schema: 'altium-toolkit.schematic.template-extraction.a1',
151
+ sourceDocument: String(documentModel?.fileName || ''),
152
+ template: template
153
+ ? {
154
+ identity,
155
+ outputTemplateKey: outputKey,
156
+ renderManifestKey: outputKey.replace(
157
+ /\.schdot$/u,
158
+ '.render.json'
159
+ ),
160
+ ownedRecordKeys: template.ownedRecordKeys || [],
161
+ ownedGraphics: template.ownedGraphics || {},
162
+ fonts: template.fonts || {},
163
+ missingParameters: template.missingParameters || [],
164
+ titleBlock: template.titleBlock || {}
165
+ }
166
+ : null,
167
+ summary: {
168
+ templatePresent: Boolean(template),
169
+ ownedRecordCount: (template?.ownedRecordKeys || []).length,
170
+ missingParameterCount: (template?.missingParameters || [])
171
+ .length,
172
+ fontCount: Object.keys(template?.fonts || {}).length
173
+ },
174
+ diagnostics
175
+ }
176
+ }
177
+
96
178
  /**
97
179
  * Builds render outputs for one schematic symbol.
98
180
  * @param {object} symbol Symbol record.
@@ -202,8 +284,14 @@ export class LibraryRenderManifestBuilder {
202
284
  )
203
285
  )
204
286
  )
287
+ const databaseLibrary =
288
+ LibraryRenderManifestBuilder.#databaseLibraryExtractionPlan(
289
+ symbolKey,
290
+ component,
291
+ schematic
292
+ )
205
293
 
206
- return {
294
+ return LibraryRenderManifestBuilder.#stripUndefined({
207
295
  kind: 'symbol-extraction',
208
296
  symbolKey,
209
297
  sourceComponent: LibraryRenderManifestBuilder.#stripUndefined({
@@ -222,8 +310,81 @@ export class LibraryRenderManifestBuilder {
222
310
  texts: children.texts.length,
223
311
  images: children.images.length
224
312
  },
225
- embeddedAssets
313
+ embeddedAssets,
314
+ databaseLibrary
315
+ })
316
+ }
317
+
318
+ /**
319
+ * Builds a database-library audit plan for one extracted symbol.
320
+ * @param {string} symbolKey Symbol extraction key.
321
+ * @param {object} component Source component row.
322
+ * @param {object} schematic Schematic model.
323
+ * @returns {object | undefined}
324
+ */
325
+ static #databaseLibraryExtractionPlan(symbolKey, component, schematic) {
326
+ const parameters = component?.parameters || {}
327
+ const parameterNames = Object.keys(parameters)
328
+ const strippedParameterNames = parameterNames.filter((name) =>
329
+ LibraryRenderManifestBuilder.#isPlacementParameterName(name)
330
+ )
331
+ const preservedParameterNames = parameterNames.filter(
332
+ (name) =>
333
+ !LibraryRenderManifestBuilder.#isPlacementParameterName(name)
334
+ )
335
+ const strippedImplementationKeys =
336
+ LibraryRenderManifestBuilder.#componentImplementationKeys(
337
+ schematic,
338
+ component
339
+ )
340
+
341
+ if (
342
+ strippedParameterNames.length === 0 &&
343
+ preservedParameterNames.length === 0 &&
344
+ strippedImplementationKeys.length === 0
345
+ ) {
346
+ return undefined
226
347
  }
348
+
349
+ return {
350
+ readiness: 'ready',
351
+ preservedParameterNames,
352
+ strippedParameterNames,
353
+ stripImplementationLinks: strippedImplementationKeys.length > 0,
354
+ strippedImplementationKeys,
355
+ auditKey: 'schematic-extract/' + symbolKey + '.dblib.json'
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Returns true for component-placement parameter names not suitable for
361
+ * extracted library symbols.
362
+ * @param {string} name Parameter name.
363
+ * @returns {boolean}
364
+ */
365
+ static #isPlacementParameterName(name) {
366
+ return ['designator', 'comment'].includes(
367
+ String(name || '').toLowerCase()
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Finds implementation keys associated with one placed component.
373
+ * @param {object} schematic Schematic model.
374
+ * @param {object} component Component row.
375
+ * @returns {string[]}
376
+ */
377
+ static #componentImplementationKeys(schematic, component) {
378
+ const ownerIndex = String(component?.ownerIndex || '').trim()
379
+ const componentKey = ownerIndex
380
+ ? 'schematic-component-' + ownerIndex
381
+ : ''
382
+
383
+ return (
384
+ schematic?.implementations?.components?.find(
385
+ (entry) => entry.componentKey === componentKey
386
+ )?.implementationKeys || []
387
+ )
227
388
  }
228
389
 
229
390
  /**
@@ -414,4 +575,13 @@ export class LibraryRenderManifestBuilder {
414
575
  .replace(/^-+|-+$/gu, '') || 'item'
415
576
  )
416
577
  }
578
+
579
+ /**
580
+ * Removes a final filename extension from a display name.
581
+ * @param {unknown} value Source value.
582
+ * @returns {string}
583
+ */
584
+ static #withoutExtension(value) {
585
+ return String(value || '').replace(/\.[A-Za-z0-9]+$/u, '')
586
+ }
417
587
  }