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
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { ProjectBomPnpReconciliationBuilder } from './ProjectBomPnpReconciliationBuilder.mjs'
6
7
  import { ProjectVariantViewBuilder } from './ProjectVariantViewBuilder.mjs'
7
8
 
8
9
  /**
@@ -114,6 +115,12 @@ export class ProjectDesignBundleBuilder {
114
115
  })
115
116
  }
116
117
 
118
+ bundle.reconciliation = ProjectBomPnpReconciliationBuilder.build({
119
+ bundle,
120
+ documentModels,
121
+ effectiveVariant: bundle.effectiveVariant
122
+ })
123
+
117
124
  return bundle
118
125
  }
119
126
 
@@ -309,15 +316,67 @@ export class ProjectDesignBundleBuilder {
309
316
  * @returns {object[]}
310
317
  */
311
318
  static #buildBom(documentModels) {
319
+ const noBomDesignators =
320
+ ProjectDesignBundleBuilder.#noBomDesignators(documentModels)
312
321
  const pcbBom = documentModels
313
322
  .filter((model) => model?.kind === 'pcb')
314
323
  .flatMap((model) => model.bom || [])
315
324
 
316
325
  if (pcbBom.length) {
317
- return pcbBom
326
+ return ProjectDesignBundleBuilder.#filterBomRows(
327
+ pcbBom,
328
+ noBomDesignators
329
+ )
330
+ }
331
+
332
+ return ProjectDesignBundleBuilder.#filterBomRows(
333
+ documentModels.flatMap((model) => model.bom || []),
334
+ noBomDesignators
335
+ )
336
+ }
337
+
338
+ /**
339
+ * Removes component-kind no-BOM designators from normalized BOM rows.
340
+ * @param {object[]} rows BOM rows.
341
+ * @param {Set<string>} noBomDesignators Designators excluded from BOMs.
342
+ * @returns {object[]}
343
+ */
344
+ static #filterBomRows(rows, noBomDesignators) {
345
+ if (!noBomDesignators.size) return rows
346
+
347
+ return (rows || [])
348
+ .map((row) => {
349
+ const designators = (row.designators || []).filter(
350
+ (designator) => !noBomDesignators.has(designator)
351
+ )
352
+ return {
353
+ ...row,
354
+ designators,
355
+ quantity: designators.length || row.quantity
356
+ }
357
+ })
358
+ .filter((row) => row.designators.length > 0)
359
+ }
360
+
361
+ /**
362
+ * Collects PCB components whose native kind excludes BOM output.
363
+ * @param {object[]} documentModels Parsed document models.
364
+ * @returns {Set<string>}
365
+ */
366
+ static #noBomDesignators(documentModels) {
367
+ const designators = new Set()
368
+
369
+ for (const model of documentModels.filter(
370
+ (item) => item?.kind === 'pcb'
371
+ )) {
372
+ for (const component of model.pcb?.components || []) {
373
+ if (component.componentKind?.includeInBom !== false) continue
374
+ const designator = String(component.designator || '').trim()
375
+ if (designator) designators.add(designator)
376
+ }
318
377
  }
319
378
 
320
- return documentModels.flatMap((model) => model.bom || [])
379
+ return designators
321
380
  }
322
381
 
323
382
  /**
@@ -14,7 +14,8 @@ export class ProjectOutJobDigestBuilder {
14
14
  * @returns {object}
15
15
  */
16
16
  static build(project) {
17
- const documents = (project?.documents || [])
17
+ const projectDocuments = project?.documents || []
18
+ const documents = projectDocuments
18
19
  .filter((document) => document.kind === 'output-job')
19
20
  .map((document) => ({
20
21
  documentIndex: document.index,
@@ -22,23 +23,39 @@ export class ProjectOutJobDigestBuilder {
22
23
  normalizedPath: document.normalizedPath,
23
24
  fileName: document.fileName
24
25
  }))
26
+ const context = {
27
+ defaultPcbDocumentPath:
28
+ projectDocuments.find((document) => document.kind === 'pcb')
29
+ ?.path || ''
30
+ }
25
31
  const outputGroups = (project?.outputGroups || []).map((group) =>
26
- ProjectOutJobDigestBuilder.#outputGroup(group)
32
+ ProjectOutJobDigestBuilder.#outputGroup(group, context)
27
33
  )
28
34
  const outputCount = outputGroups.reduce(
29
35
  (sum, group) => sum + group.outputCount,
30
36
  0
31
37
  )
38
+ const outputs = outputGroups.flatMap((group) => group.outputs)
39
+ const expectedArtifacts =
40
+ ProjectOutJobDigestBuilder.#expectedArtifacts(outputGroups)
32
41
 
33
42
  return {
34
43
  schema: ProjectOutJobDigestBuilder.SCHEMA_ID,
35
44
  summary: {
36
45
  outJobDocumentCount: documents.length,
37
46
  outputGroupCount: outputGroups.length,
38
- outputCount
47
+ outputCount,
48
+ typedOutputCount: outputs.filter(
49
+ (output) => output.normalizedType !== 'unsupported'
50
+ ).length,
51
+ unsupportedOutputCount: outputs.filter(
52
+ (output) => output.normalizedType === 'unsupported'
53
+ ).length,
54
+ expectedArtifactCount: expectedArtifacts.manifest.outputs.length
39
55
  },
40
56
  documents,
41
57
  outputGroups,
58
+ expectedArtifacts,
42
59
  outputsByDocumentPath:
43
60
  ProjectOutJobDigestBuilder.#outputsByDocumentPath(outputGroups)
44
61
  }
@@ -47,19 +64,12 @@ export class ProjectOutJobDigestBuilder {
47
64
  /**
48
65
  * Normalizes one output group.
49
66
  * @param {object} group Project output group.
67
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
50
68
  * @returns {object}
51
69
  */
52
- static #outputGroup(group) {
70
+ static #outputGroup(group, context) {
53
71
  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
72
+ ...ProjectOutJobDigestBuilder.#typedOutput(output, group, context)
63
73
  }))
64
74
 
65
75
  return {
@@ -88,6 +98,7 @@ export class ProjectOutJobDigestBuilder {
88
98
  outputGroupIndex: group.index,
89
99
  outputIndex: output.index,
90
100
  type: output.type,
101
+ normalizedType: output.normalizedType,
91
102
  name: output.name,
92
103
  variantName: output.variantName,
93
104
  isDefault: output.isDefault
@@ -106,4 +117,404 @@ export class ProjectOutJobDigestBuilder {
106
117
  static #normalizePath(path) {
107
118
  return String(path || '').replace(/\\/g, '/')
108
119
  }
120
+
121
+ /**
122
+ * Builds one typed output row.
123
+ * @param {object} output Raw output row.
124
+ * @param {object} group Owning output group.
125
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
126
+ * @returns {object}
127
+ */
128
+ static #typedOutput(output, group, context) {
129
+ const config = ProjectOutJobDigestBuilder.#mergedConfig(output)
130
+ const normalizedType = ProjectOutJobDigestBuilder.#normalizedType(
131
+ output.type
132
+ )
133
+ const category = ProjectOutJobDigestBuilder.#category(normalizedType)
134
+ const documentPath = ProjectOutJobDigestBuilder.#documentPath(
135
+ normalizedType,
136
+ output,
137
+ config,
138
+ context
139
+ )
140
+ const normalizedDocumentPath =
141
+ ProjectOutJobDigestBuilder.#normalizePath(documentPath)
142
+ const settings = ProjectOutJobDigestBuilder.#settings(
143
+ normalizedType,
144
+ output,
145
+ config,
146
+ documentPath
147
+ )
148
+ const base = {
149
+ index: output.index,
150
+ type: output.type,
151
+ normalizedType,
152
+ name: output.name,
153
+ documentPath: output.documentPath || '',
154
+ normalizedDocumentPath,
155
+ variantName: output.variantName || config.VariantName || '',
156
+ isDefault: output.isDefault,
157
+ category,
158
+ settings
159
+ }
160
+
161
+ return {
162
+ ...base,
163
+ expectedArtifact: ProjectOutJobDigestBuilder.#expectedArtifact(
164
+ base,
165
+ group
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Resolves the source document path for one output row.
172
+ * @param {string} normalizedType Stable output type.
173
+ * @param {object} output Raw output row.
174
+ * @param {Record<string, string>} config Merged config fields.
175
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
176
+ * @returns {string}
177
+ */
178
+ static #documentPath(normalizedType, output, config, context) {
179
+ const explicit = output.documentPath || config.DocumentPath || ''
180
+ if (explicit) return explicit
181
+
182
+ if (
183
+ normalizedType === 'bom' &&
184
+ ProjectOutJobDigestBuilder.#boolean(config.IncludePcbData) === true
185
+ ) {
186
+ return context.defaultPcbDocumentPath || ''
187
+ }
188
+
189
+ return ''
190
+ }
191
+
192
+ /**
193
+ * Merges parsed configuration rows into one field lookup.
194
+ * @param {object} output Output row.
195
+ * @returns {Record<string, string>}
196
+ */
197
+ static #mergedConfig(output) {
198
+ const config = {}
199
+
200
+ for (const row of output.configRows || []) {
201
+ if (row.record && !config.Record) {
202
+ config.Record = row.record
203
+ }
204
+ Object.assign(config, row.fields || {})
205
+ }
206
+
207
+ return config
208
+ }
209
+
210
+ /**
211
+ * Resolves a stable output type token.
212
+ * @param {string} type Native output type.
213
+ * @returns {string}
214
+ */
215
+ static #normalizedType(type) {
216
+ const normalized = String(type || '')
217
+ .trim()
218
+ .toLowerCase()
219
+ .replace(/[\s_]+/gu, '-')
220
+
221
+ if (normalized.includes('gerber')) return 'gerber'
222
+ if (normalized.includes('ncdrill') || normalized.includes('nc-drill')) {
223
+ return 'nc-drill'
224
+ }
225
+ if (normalized.includes('odb')) return 'odb'
226
+ if (
227
+ normalized.includes('pickplace') ||
228
+ normalized.includes('pick-place')
229
+ ) {
230
+ return 'pick-place'
231
+ }
232
+ if (normalized.includes('wirelist')) return 'wirelist'
233
+ if (normalized.includes('bom')) return 'bom'
234
+ if (normalized.includes('step')) return 'step'
235
+ if (normalized.includes('schematicprint')) return 'schematic-print'
236
+ if (
237
+ normalized.includes('pcbdrawing') ||
238
+ normalized.includes('draftsman')
239
+ ) {
240
+ return 'pcb-drawing'
241
+ }
242
+
243
+ return 'unsupported'
244
+ }
245
+
246
+ /**
247
+ * Resolves an output category.
248
+ * @param {string} normalizedType Stable output type.
249
+ * @returns {string}
250
+ */
251
+ static #category(normalizedType) {
252
+ return (
253
+ {
254
+ gerber: 'fabrication',
255
+ 'nc-drill': 'fabrication',
256
+ odb: 'fabrication',
257
+ 'pick-place': 'assembly',
258
+ wirelist: 'netlist',
259
+ bom: 'report',
260
+ step: 'export',
261
+ 'schematic-print': 'documentation',
262
+ 'pcb-drawing': 'documentation'
263
+ }[normalizedType] || 'unsupported'
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Builds typed settings for one output row.
269
+ * @param {string} normalizedType Stable output type.
270
+ * @param {object} output Output row.
271
+ * @param {Record<string, string>} config Merged config fields.
272
+ * @param {string} documentPath Resolved output document path.
273
+ * @returns {object}
274
+ */
275
+ static #settings(normalizedType, output, config, documentPath) {
276
+ const common = ProjectOutJobDigestBuilder.#stripEmpty({
277
+ record: config.Record || '',
278
+ documentPath
279
+ })
280
+
281
+ switch (normalizedType) {
282
+ case 'gerber':
283
+ return ProjectOutJobDigestBuilder.#stripEmpty({
284
+ ...common,
285
+ units: config.GerberUnit || config.Units || '',
286
+ decimals: ProjectOutJobDigestBuilder.#number(
287
+ config.NumberOfDecimals
288
+ ),
289
+ plotLayers: ProjectOutJobDigestBuilder.#plotLayers(
290
+ config['Plot.Set']
291
+ )
292
+ })
293
+ case 'nc-drill':
294
+ return ProjectOutJobDigestBuilder.#stripEmpty({
295
+ ...common,
296
+ units: config.Units || '',
297
+ separatePlated:
298
+ ProjectOutJobDigestBuilder.#boolean(
299
+ config.SeparatePlated
300
+ ) ??
301
+ ProjectOutJobDigestBuilder.#boolean(
302
+ config.GenerateSeparatePlatedNonPlatedFiles
303
+ )
304
+ })
305
+ case 'pick-place':
306
+ return ProjectOutJobDigestBuilder.#stripEmpty({
307
+ ...common,
308
+ units: config.Units || '',
309
+ generateCsv: ProjectOutJobDigestBuilder.#boolean(
310
+ config.GenerateCSVFormat
311
+ ),
312
+ includeStandardNoBom: ProjectOutJobDigestBuilder.#boolean(
313
+ config.IncludeStandardNoBOM
314
+ )
315
+ })
316
+ case 'wirelist':
317
+ return ProjectOutJobDigestBuilder.#stripEmpty({
318
+ ...common,
319
+ units: config.Units || '',
320
+ generateText: ProjectOutJobDigestBuilder.#boolean(
321
+ config.GenerateTextFormat
322
+ ),
323
+ includeVariations: ProjectOutJobDigestBuilder.#boolean(
324
+ config.IncludeVariations
325
+ )
326
+ })
327
+ case 'bom':
328
+ return ProjectOutJobDigestBuilder.#stripEmpty({
329
+ ...common,
330
+ includePcbData: ProjectOutJobDigestBuilder.#boolean(
331
+ config.IncludePcbData
332
+ ),
333
+ includeAlternatives: ProjectOutJobDigestBuilder.#boolean(
334
+ config.IncludeAlternatives
335
+ ),
336
+ batchMode: ProjectOutJobDigestBuilder.#number(
337
+ config.BatchMode
338
+ ),
339
+ viewType: ProjectOutJobDigestBuilder.#number(
340
+ config.ViewType
341
+ )
342
+ })
343
+ case 'step':
344
+ return ProjectOutJobDigestBuilder.#stripEmpty({
345
+ ...common,
346
+ exportModelsOption: ProjectOutJobDigestBuilder.#number(
347
+ config.ExportModelsOption
348
+ ),
349
+ exportHolesOption: ProjectOutJobDigestBuilder.#number(
350
+ config.ExportHolesOption
351
+ )
352
+ })
353
+ case 'schematic-print':
354
+ return ProjectOutJobDigestBuilder.#stripEmpty({
355
+ ...common,
356
+ paperKind: config.PaperKind || '',
357
+ printScale: ProjectOutJobDigestBuilder.#number(
358
+ config.PrintScale
359
+ )
360
+ })
361
+ case 'pcb-drawing':
362
+ return ProjectOutJobDigestBuilder.#stripEmpty({
363
+ ...common,
364
+ variantName: config.VariantName || ''
365
+ })
366
+ default:
367
+ return common
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Builds expected artifact metadata for all output rows.
373
+ * @param {object[]} outputGroups Output groups.
374
+ * @returns {object}
375
+ */
376
+ static #expectedArtifacts(outputGroups) {
377
+ const outputs = outputGroups.flatMap((group) =>
378
+ group.outputs.map((output) => output.expectedArtifact)
379
+ )
380
+
381
+ return {
382
+ schema: 'altium-toolkit.project.expected-artifacts.a1',
383
+ summary: {
384
+ outputCount: outputs.length,
385
+ unsupportedOutputCount: outputs.filter(
386
+ (output) => output.unsupported
387
+ ).length
388
+ },
389
+ manifest: {
390
+ outputs
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Builds one expected artifact row.
397
+ * @param {object} output Typed output row.
398
+ * @param {object} group Owning group.
399
+ * @returns {object}
400
+ */
401
+ static #expectedArtifact(output, group) {
402
+ const artifact = ProjectOutJobDigestBuilder.#stripEmpty({
403
+ key:
404
+ ProjectOutJobDigestBuilder.#slug(group.name || 'outputs') +
405
+ '/' +
406
+ String(output.index).padStart(2, '0') +
407
+ '-' +
408
+ ProjectOutJobDigestBuilder.#slug(
409
+ output.name || output.normalizedType
410
+ ),
411
+ outputGroupName: group.name || '',
412
+ outputName: output.name || '',
413
+ outputType: output.normalizedType,
414
+ category: output.category,
415
+ documentPath:
416
+ output.settings.documentPath || output.documentPath || '',
417
+ normalizedDocumentPath: output.normalizedDocumentPath,
418
+ variantName: output.variantName,
419
+ format: ProjectOutJobDigestBuilder.#format(output),
420
+ units: output.settings.units,
421
+ unsupported:
422
+ output.normalizedType === 'unsupported' ? true : undefined
423
+ })
424
+ artifact.variantName = output.variantName || ''
425
+ return artifact
426
+ }
427
+
428
+ /**
429
+ * Resolves the expected artifact format token.
430
+ * @param {object} output Typed output row.
431
+ * @returns {string}
432
+ */
433
+ static #format(output) {
434
+ if (output.normalizedType === 'pick-place') {
435
+ return output.settings.generateCsv === false
436
+ ? 'pick-place-text'
437
+ : 'pick-place-csv'
438
+ }
439
+
440
+ return (
441
+ {
442
+ gerber: 'gerber',
443
+ 'nc-drill': 'nc-drill',
444
+ odb: 'odb',
445
+ wirelist: 'wirelist',
446
+ bom: 'bom',
447
+ step: 'step',
448
+ 'schematic-print': 'pdf',
449
+ 'pcb-drawing': 'pcbdwf'
450
+ }[output.normalizedType] || 'unknown'
451
+ )
452
+ }
453
+
454
+ /**
455
+ * Parses plot-layer tokens from Altium plot-layer state strings.
456
+ * @param {string | undefined} value Plot-layer state.
457
+ * @returns {string[] | undefined}
458
+ */
459
+ static #plotLayers(value) {
460
+ const layers = String(value || '')
461
+ .split(',')
462
+ .map((segment) => segment.trim().split('~')[0])
463
+ .filter(Boolean)
464
+
465
+ return layers.length ? layers : undefined
466
+ }
467
+
468
+ /**
469
+ * Parses one numeric field.
470
+ * @param {unknown} value Raw value.
471
+ * @returns {number | undefined}
472
+ */
473
+ static #number(value) {
474
+ const parsed = Number.parseFloat(String(value ?? '').trim())
475
+ return Number.isFinite(parsed) ? parsed : undefined
476
+ }
477
+
478
+ /**
479
+ * Parses one optional boolean field.
480
+ * @param {unknown} value Raw value.
481
+ * @returns {boolean | undefined}
482
+ */
483
+ static #boolean(value) {
484
+ const raw = String(value ?? '')
485
+ .trim()
486
+ .toLowerCase()
487
+ if (!raw) return undefined
488
+ return ['1', 't', 'true', 'yes'].includes(raw)
489
+ }
490
+
491
+ /**
492
+ * Removes empty values while preserving booleans and zeroes.
493
+ * @param {object} value Source object.
494
+ * @returns {object}
495
+ */
496
+ static #stripEmpty(value) {
497
+ return Object.fromEntries(
498
+ Object.entries(value || {}).filter(([, entryValue]) => {
499
+ if (Array.isArray(entryValue)) return entryValue.length > 0
500
+ if (typeof entryValue === 'string') return entryValue.length > 0
501
+ return entryValue !== null && entryValue !== undefined
502
+ })
503
+ )
504
+ }
505
+
506
+ /**
507
+ * Builds a stable slug for output manifest keys.
508
+ * @param {string} value Raw value.
509
+ * @returns {string}
510
+ */
511
+ static #slug(value) {
512
+ return (
513
+ String(value || '')
514
+ .trim()
515
+ .toLowerCase()
516
+ .replace(/[^a-z0-9]+/gu, '-')
517
+ .replace(/^-|-$/gu, '') || 'output'
518
+ )
519
+ }
109
520
  }
@@ -15,6 +15,18 @@ export class SvgModelCrossLinkValidator {
15
15
  * @returns {object}
16
16
  */
17
17
  static validate(documentModel, svgMarkup) {
18
+ return SvgModelCrossLinkValidator.validateSet(documentModel, [
19
+ svgMarkup
20
+ ])
21
+ }
22
+
23
+ /**
24
+ * Validates a set of semantic SVG fragments against one normalized model.
25
+ * @param {object} documentModel Normalized schematic or PCB model.
26
+ * @param {string[]} svgMarkups SVG markup strings.
27
+ * @returns {object}
28
+ */
29
+ static validateSet(documentModel, svgMarkups) {
18
30
  const documentKind =
19
31
  SvgModelCrossLinkValidator.#documentKind(documentModel)
20
32
  const expectedElements =
@@ -22,7 +34,9 @@ export class SvgModelCrossLinkValidator {
22
34
  const expectedByKey = new Map(
23
35
  expectedElements.map((element) => [element.elementKey, element])
24
36
  )
25
- const svgElements = SvgModelCrossLinkValidator.#svgElements(svgMarkup)
37
+ const svgElements = (svgMarkups || []).flatMap((svgMarkup) =>
38
+ SvgModelCrossLinkValidator.#svgElements(svgMarkup)
39
+ )
26
40
  const renderedKeys = new Set(
27
41
  svgElements.map((element) => element.elementKey).filter(Boolean)
28
42
  )
@@ -41,12 +55,13 @@ export class SvgModelCrossLinkValidator {
41
55
  documentModel,
42
56
  svgElements
43
57
  )
44
- const metadata = SvgModelCrossLinkValidator.#metadata(svgMarkup)
58
+ const metadata = SvgModelCrossLinkValidator.#metadataSet(svgMarkups)
45
59
 
46
60
  return {
47
61
  schema: SvgModelCrossLinkValidator.SCHEMA,
48
62
  documentKind,
49
63
  summary: {
64
+ svgCount: (svgMarkups || []).length,
50
65
  expectedElementCount: expectedElements.length,
51
66
  renderedElementCount: renderedKeys.size,
52
67
  linkedElementCount:
@@ -375,6 +390,24 @@ export class SvgModelCrossLinkValidator {
375
390
  }
376
391
  }
377
392
 
393
+ /**
394
+ * Extracts semantic metadata from a set of SVG fragments.
395
+ * @param {string[]} svgMarkups SVG markup strings.
396
+ * @returns {{ schema: string, elements: object[] }}
397
+ */
398
+ static #metadataSet(svgMarkups) {
399
+ const metadataRows = (svgMarkups || []).map((svgMarkup) =>
400
+ SvgModelCrossLinkValidator.#metadata(svgMarkup)
401
+ )
402
+ const schema =
403
+ metadataRows.find((metadata) => metadata.schema)?.schema || ''
404
+
405
+ return {
406
+ schema,
407
+ elements: metadataRows.flatMap((metadata) => metadata.elements)
408
+ }
409
+ }
410
+
378
411
  /**
379
412
  * Converts a data attribute token to a camelCase object key.
380
413
  * @param {string} value Attribute token.