altium-toolkit 1.0.9 → 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 (30) hide show
  1. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  2. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  3. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +160 -1
  5. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  8. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  9. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  10. package/package.json +1 -1
  11. package/src/core/altium/AltiumParser.mjs +7 -2
  12. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  13. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  14. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  15. package/src/core/altium/PcbModelParser.mjs +29 -4
  16. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  17. package/src/core/altium/PcbPickPlacePositionResolver.mjs +8 -1
  18. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  19. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  20. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  21. package/src/core/altium/PrjPcbModelParser.mjs +24 -2
  22. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  23. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  24. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  25. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  26. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  27. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  28. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  29. package/src/parser.mjs +6 -0
  30. package/src/ui/PcbSvgRenderer.mjs +65 -0
@@ -0,0 +1,402 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Validates that semantic SVG links point back to normalized model entries.
7
+ */
8
+ export class SvgModelCrossLinkValidator {
9
+ static SCHEMA = 'altium-toolkit.svg-model-cross-link.a1'
10
+
11
+ /**
12
+ * Validates semantic SVG data attributes against a normalized model.
13
+ * @param {object} documentModel Normalized schematic or PCB model.
14
+ * @param {string} svgMarkup SVG markup.
15
+ * @returns {object}
16
+ */
17
+ static validate(documentModel, svgMarkup) {
18
+ const documentKind =
19
+ SvgModelCrossLinkValidator.#documentKind(documentModel)
20
+ const expectedElements =
21
+ SvgModelCrossLinkValidator.#expectedElements(documentModel)
22
+ const expectedByKey = new Map(
23
+ expectedElements.map((element) => [element.elementKey, element])
24
+ )
25
+ const svgElements = SvgModelCrossLinkValidator.#svgElements(svgMarkup)
26
+ const renderedKeys = new Set(
27
+ svgElements.map((element) => element.elementKey).filter(Boolean)
28
+ )
29
+ const orphanElements = SvgModelCrossLinkValidator.#orphanElements(
30
+ svgElements,
31
+ expectedByKey,
32
+ documentKind
33
+ )
34
+ const missingElements = SvgModelCrossLinkValidator.#missingElements(
35
+ expectedElements,
36
+ renderedKeys,
37
+ documentModel.diagnostics || []
38
+ )
39
+ const unresolvedReferences =
40
+ SvgModelCrossLinkValidator.#unresolvedReferences(
41
+ documentModel,
42
+ svgElements
43
+ )
44
+ const metadata = SvgModelCrossLinkValidator.#metadata(svgMarkup)
45
+
46
+ return {
47
+ schema: SvgModelCrossLinkValidator.SCHEMA,
48
+ documentKind,
49
+ summary: {
50
+ expectedElementCount: expectedElements.length,
51
+ renderedElementCount: renderedKeys.size,
52
+ linkedElementCount:
53
+ expectedElements.length - missingElements.length,
54
+ missingElementCount: missingElements.length,
55
+ orphanElementCount: orphanElements.length,
56
+ unresolvedReferenceCount: unresolvedReferences.length,
57
+ metadataElementCount: metadata.elements.length
58
+ },
59
+ missingElements,
60
+ orphanElements,
61
+ unresolvedReferences,
62
+ metadata
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Determines the model kind for report output.
68
+ * @param {object} documentModel Normalized document model.
69
+ * @returns {'schematic' | 'pcb' | 'unknown'}
70
+ */
71
+ static #documentKind(documentModel) {
72
+ if (documentModel?.schematic) return 'schematic'
73
+ if (documentModel?.pcb) return 'pcb'
74
+ return 'unknown'
75
+ }
76
+
77
+ /**
78
+ * Builds expected semantic element keys from a normalized model.
79
+ * @param {object} documentModel Normalized document model.
80
+ * @returns {object[]}
81
+ */
82
+ static #expectedElements(documentModel) {
83
+ if (documentModel?.schematic) {
84
+ return SvgModelCrossLinkValidator.#schematicExpectedElements(
85
+ documentModel.schematic
86
+ )
87
+ }
88
+ if (documentModel?.pcb) {
89
+ return SvgModelCrossLinkValidator.#pcbExpectedElements(
90
+ documentModel.pcb
91
+ )
92
+ }
93
+ return []
94
+ }
95
+
96
+ /**
97
+ * Builds expected schematic element descriptors.
98
+ * @param {object} schematic Normalized schematic payload.
99
+ * @returns {object[]}
100
+ */
101
+ static #schematicExpectedElements(schematic) {
102
+ return SvgModelCrossLinkValidator.#collectionElements('schematic', [
103
+ ['lines', 'line', schematic?.lines || []],
104
+ ['polygons', 'polygon', schematic?.polygons || []],
105
+ ['rectangles', 'rectangle', schematic?.rectangles || []],
106
+ [
107
+ 'roundedRectangles',
108
+ 'rounded-rectangle',
109
+ schematic?.roundedRectangles || []
110
+ ],
111
+ ['ellipses', 'ellipse', schematic?.ellipses || []],
112
+ ['arcs', 'arc', schematic?.arcs || []],
113
+ ['beziers', 'bezier', schematic?.beziers || []],
114
+ ['pies', 'pie', schematic?.pies || []],
115
+ ['ieeeSymbols', 'ieee-symbol', schematic?.ieeeSymbols || []],
116
+ ['texts', 'text', schematic?.texts || []],
117
+ ['pins', 'pin', schematic?.pins || []],
118
+ ['ports', 'port', schematic?.ports || []],
119
+ ['directives', 'directive', schematic?.directives || []]
120
+ ])
121
+ }
122
+
123
+ /**
124
+ * Builds expected PCB element descriptors.
125
+ * @param {object} pcb Normalized PCB payload.
126
+ * @returns {object[]}
127
+ */
128
+ static #pcbExpectedElements(pcb) {
129
+ return SvgModelCrossLinkValidator.#collectionElements('pcb', [
130
+ ['polygons', 'polygon', pcb?.polygons || []],
131
+ ['fills', 'fill', pcb?.fills || []],
132
+ ['tracks', 'track', pcb?.tracks || []],
133
+ ['arcs', 'arc', pcb?.arcs || []],
134
+ ['vias', 'via', pcb?.vias || []],
135
+ ['pads', 'pad', pcb?.pads || []],
136
+ ['texts', 'text', pcb?.texts || []],
137
+ ['components', 'component', pcb?.components || []]
138
+ ])
139
+ }
140
+
141
+ /**
142
+ * Builds descriptors for primitive collections.
143
+ * @param {'schematic' | 'pcb'} prefix SVG element prefix.
144
+ * @param {[string, string, object[]][]} collections Collections to inspect.
145
+ * @returns {object[]}
146
+ */
147
+ static #collectionElements(prefix, collections) {
148
+ const elements = []
149
+ for (const [collectionKey, primitiveKind, records] of collections) {
150
+ for (const [index, record] of (records || []).entries()) {
151
+ elements.push({
152
+ elementKey: prefix + '-' + primitiveKind + '-' + index,
153
+ collectionKey,
154
+ primitiveKind,
155
+ recordId:
156
+ record?.recordId ??
157
+ record?.sourceRecordId ??
158
+ record?.sourceRecordIndex ??
159
+ ''
160
+ })
161
+ }
162
+ }
163
+ return elements
164
+ }
165
+
166
+ /**
167
+ * Extracts SVG elements that carry semantic data attributes.
168
+ * @param {string} svgMarkup SVG markup.
169
+ * @returns {object[]}
170
+ */
171
+ static #svgElements(svgMarkup) {
172
+ const elements = []
173
+ const tagPattern = /<[^>]+data-element-key="[^"]+"[^>]*>/gu
174
+ let match = tagPattern.exec(String(svgMarkup || ''))
175
+ while (match) {
176
+ const attrs = SvgModelCrossLinkValidator.#dataAttributes(match[0])
177
+ elements.push({
178
+ elementKey: attrs.elementKey || '',
179
+ primitive: attrs.primitive || '',
180
+ component: attrs.component || '',
181
+ net: attrs.net || '',
182
+ pin: attrs.pin || '',
183
+ attrs
184
+ })
185
+ match = tagPattern.exec(String(svgMarkup || ''))
186
+ }
187
+ return elements
188
+ }
189
+
190
+ /**
191
+ * Extracts data attributes from one SVG tag.
192
+ * @param {string} tag SVG tag markup.
193
+ * @returns {Record<string, string>}
194
+ */
195
+ static #dataAttributes(tag) {
196
+ const attrs = {}
197
+ const attrPattern = /data-([a-z0-9-]+)="([^"]*)"/giu
198
+ let match = attrPattern.exec(tag || '')
199
+ while (match) {
200
+ attrs[SvgModelCrossLinkValidator.#camelCase(match[1])] =
201
+ SvgModelCrossLinkValidator.#decodeEntities(match[2])
202
+ match = attrPattern.exec(tag || '')
203
+ }
204
+ return attrs
205
+ }
206
+
207
+ /**
208
+ * Finds rendered elements not represented by the normalized model.
209
+ * @param {object[]} svgElements Rendered semantic SVG rows.
210
+ * @param {Map<string, object>} expectedByKey Expected element map.
211
+ * @param {'schematic' | 'pcb' | 'unknown'} documentKind Document kind.
212
+ * @returns {object[]}
213
+ */
214
+ static #orphanElements(svgElements, expectedByKey, documentKind) {
215
+ return svgElements
216
+ .filter(
217
+ (element) =>
218
+ !expectedByKey.has(element.elementKey) &&
219
+ !SvgModelCrossLinkValidator.#isRendererOwnedElement(
220
+ element.elementKey,
221
+ documentKind
222
+ )
223
+ )
224
+ .map((element) => ({
225
+ elementKey: element.elementKey,
226
+ primitive: element.primitive || ''
227
+ }))
228
+ }
229
+
230
+ /**
231
+ * Finds normalized elements that are not represented in SVG output.
232
+ * @param {object[]} expectedElements Expected model element rows.
233
+ * @param {Set<string>} renderedKeys Rendered SVG element keys.
234
+ * @param {object[]} diagnostics Model diagnostics.
235
+ * @returns {object[]}
236
+ */
237
+ static #missingElements(expectedElements, renderedKeys, diagnostics) {
238
+ return expectedElements
239
+ .filter(
240
+ (element) =>
241
+ !renderedKeys.has(element.elementKey) &&
242
+ !SvgModelCrossLinkValidator.#hasDiagnostic(
243
+ element,
244
+ diagnostics
245
+ )
246
+ )
247
+ .map((element) => ({
248
+ elementKey: element.elementKey,
249
+ collectionKey: element.collectionKey,
250
+ primitiveKind: element.primitiveKind,
251
+ recordId: element.recordId || undefined
252
+ }))
253
+ }
254
+
255
+ /**
256
+ * Checks whether a missing element has an explicit diagnostic.
257
+ * @param {object} element Expected element row.
258
+ * @param {object[]} diagnostics Model diagnostics.
259
+ * @returns {boolean}
260
+ */
261
+ static #hasDiagnostic(element, diagnostics) {
262
+ return (diagnostics || []).some((diagnostic) => {
263
+ const message = String(diagnostic?.message || '')
264
+ return (
265
+ diagnostic?.elementKey === element.elementKey ||
266
+ (element.recordId &&
267
+ diagnostic?.recordId === element.recordId) ||
268
+ message.includes(element.elementKey)
269
+ )
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Finds component/net references that cannot be resolved in the model.
275
+ * @param {object} documentModel Normalized document model.
276
+ * @param {object[]} svgElements Rendered semantic SVG rows.
277
+ * @returns {object[]}
278
+ */
279
+ static #unresolvedReferences(documentModel, svgElements) {
280
+ const components =
281
+ SvgModelCrossLinkValidator.#componentNames(documentModel)
282
+ const nets = SvgModelCrossLinkValidator.#netNames(documentModel)
283
+ const unresolved = []
284
+
285
+ for (const element of svgElements) {
286
+ if (element.component && !components.has(element.component)) {
287
+ unresolved.push({
288
+ elementKey: element.elementKey,
289
+ referenceKind: 'component',
290
+ value: element.component
291
+ })
292
+ }
293
+ if (element.net && !nets.has(element.net)) {
294
+ unresolved.push({
295
+ elementKey: element.elementKey,
296
+ referenceKind: 'net',
297
+ value: element.net
298
+ })
299
+ }
300
+ }
301
+
302
+ return unresolved
303
+ }
304
+
305
+ /**
306
+ * Collects normalized component designators.
307
+ * @param {object} documentModel Normalized document model.
308
+ * @returns {Set<string>}
309
+ */
310
+ static #componentNames(documentModel) {
311
+ const components =
312
+ documentModel?.schematic?.components ||
313
+ documentModel?.pcb?.components ||
314
+ []
315
+ return new Set(
316
+ components
317
+ .map((component) => String(component?.designator || ''))
318
+ .filter(Boolean)
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Collects normalized net names.
324
+ * @param {object} documentModel Normalized document model.
325
+ * @returns {Set<string>}
326
+ */
327
+ static #netNames(documentModel) {
328
+ const nets =
329
+ documentModel?.schematic?.nets || documentModel?.pcb?.nets || []
330
+ return new Set(
331
+ nets.map((net) => String(net?.name || '')).filter(Boolean)
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Returns true for renderer-owned semantic helpers.
337
+ * @param {string} elementKey SVG element key.
338
+ * @param {'schematic' | 'pcb' | 'unknown'} documentKind Document kind.
339
+ * @returns {boolean}
340
+ */
341
+ static #isRendererOwnedElement(elementKey, documentKind) {
342
+ if (documentKind !== 'pcb') return false
343
+ return (
344
+ elementKey === 'pcb-board-outline' ||
345
+ elementKey === 'pcb-board-outline-stroke' ||
346
+ /^pcb-board-cutout-\d+$/u.test(elementKey) ||
347
+ /^pcb-(via|pad)-hole-\d+$/u.test(elementKey)
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Extracts the semantic metadata JSON sidecar when present.
353
+ * @param {string} svgMarkup SVG markup.
354
+ * @returns {{ schema: string, elements: object[] }}
355
+ */
356
+ static #metadata(svgMarkup) {
357
+ const match = String(svgMarkup || '').match(
358
+ /<metadata id="(?:schematic|pcb)-semantic-metadata"[^>]*>([^<]*)<\/metadata>/u
359
+ )
360
+ if (!match) {
361
+ return { schema: '', elements: [] }
362
+ }
363
+ try {
364
+ const metadata = JSON.parse(
365
+ SvgModelCrossLinkValidator.#decodeEntities(match[1])
366
+ )
367
+ return {
368
+ schema: metadata.schema || '',
369
+ elements: Array.isArray(metadata.elements)
370
+ ? metadata.elements
371
+ : []
372
+ }
373
+ } catch {
374
+ return { schema: '', elements: [] }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Converts a data attribute token to a camelCase object key.
380
+ * @param {string} value Attribute token.
381
+ * @returns {string}
382
+ */
383
+ static #camelCase(value) {
384
+ return String(value || '').replace(/-([a-z0-9])/giu, (_match, char) =>
385
+ String(char).toUpperCase()
386
+ )
387
+ }
388
+
389
+ /**
390
+ * Decodes basic XML entities.
391
+ * @param {string} value Encoded value.
392
+ * @returns {string}
393
+ */
394
+ static #decodeEntities(value) {
395
+ return String(value || '')
396
+ .replace(/&quot;/gu, '"')
397
+ .replace(/&apos;/gu, "'")
398
+ .replace(/&lt;/gu, '<')
399
+ .replace(/&gt;/gu, '>')
400
+ .replace(/&amp;/gu, '&')
401
+ }
402
+ }