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,653 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds Draftsman board-view/cache metadata sidecars from text containers.
7
+ */
8
+ export class DraftsmanBoardViewMetadataBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.draftsman.board-view-cache.a1'
10
+
11
+ /**
12
+ * Normalizes board-view/cache metadata used by drawing review surfaces.
13
+ * @param {string} text Decoded container text.
14
+ * @param {object[]} pages Parsed pages.
15
+ * @returns {object | undefined}
16
+ */
17
+ static build(text, pages) {
18
+ const layerColors = DraftsmanBoardViewMetadataBuilder.#globalTagFields(
19
+ text,
20
+ ['LayerColor', 'LayerColorV2']
21
+ ).map((fields) =>
22
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
23
+ id: fields.Id || fields.ID,
24
+ layerId: DraftsmanBoardViewMetadataBuilder.#integer(
25
+ fields.LayerId
26
+ ),
27
+ layerName:
28
+ fields.LayerName || fields.DisplayName || fields.Name,
29
+ role: fields.Role || fields.LayerRole,
30
+ color: fields.Color,
31
+ fields
32
+ })
33
+ )
34
+ const pcbParameters = Object.fromEntries(
35
+ DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
36
+ 'PCBParameter',
37
+ 'DrawingDocumentParameterData'
38
+ ])
39
+ .map((fields) => [
40
+ fields.Name || fields.ParameterName || '',
41
+ fields.Value || ''
42
+ ])
43
+ .filter(([name]) => name)
44
+ )
45
+ const boardAssemblyViews =
46
+ DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
47
+ 'BoardAssemblyView',
48
+ 'BoardAssemblyInformation'
49
+ ]).map((fields) =>
50
+ DraftsmanBoardViewMetadataBuilder.#boardAssemblyView(fields)
51
+ )
52
+ const boardFabricationViews =
53
+ DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
54
+ 'BoardFabricationView',
55
+ 'BoardFabricationInformation'
56
+ ]).map((fields) =>
57
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
58
+ id: fields.Id || fields.ID,
59
+ pageId: fields.PageId,
60
+ sourceDocumentName:
61
+ fields.SourceDocumentName || fields.SourceDocument,
62
+ drillTableId: fields.DrillTableId,
63
+ fields
64
+ })
65
+ )
66
+ const boardProjections =
67
+ DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
68
+ 'BoardProjection',
69
+ 'BoardProjectionInformation'
70
+ ]).map((fields) =>
71
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
72
+ id: fields.Id || fields.ID,
73
+ source: fields.Source,
74
+ width: DraftsmanBoardViewMetadataBuilder.#number(
75
+ fields.Width
76
+ ),
77
+ height: DraftsmanBoardViewMetadataBuilder.#number(
78
+ fields.Height
79
+ ),
80
+ scale: DraftsmanBoardViewMetadataBuilder.#number(
81
+ fields.Scale
82
+ ),
83
+ fields
84
+ })
85
+ )
86
+ const generatedGeometry =
87
+ DraftsmanBoardViewMetadataBuilder.#generatedGeometry(text, pages)
88
+ const cacheLayers = DraftsmanBoardViewMetadataBuilder.#cacheLayers(text)
89
+ const displayLayers =
90
+ DraftsmanBoardViewMetadataBuilder.#displayLayers(text)
91
+ const cachePrimitives =
92
+ DraftsmanBoardViewMetadataBuilder.#cachePrimitives(text, pages)
93
+ const highlightGroups =
94
+ DraftsmanBoardViewMetadataBuilder.#highlightGroups(text)
95
+ const layerTiles = DraftsmanBoardViewMetadataBuilder.#layerTiles(
96
+ text,
97
+ pages
98
+ )
99
+ const diagnostics = DraftsmanBoardViewMetadataBuilder.#diagnostics({
100
+ cacheLayers,
101
+ displayLayers,
102
+ cachePrimitives,
103
+ highlightGroups,
104
+ layerTiles
105
+ })
106
+ const summary = {
107
+ layerColorCount: layerColors.length,
108
+ pcbParameterCount: Object.keys(pcbParameters).length,
109
+ boardAssemblyViewCount: boardAssemblyViews.length,
110
+ boardFabricationViewCount: boardFabricationViews.length,
111
+ boardProjectionCount: boardProjections.length,
112
+ generatedGeometryCount: generatedGeometry.length,
113
+ cacheLayerCount: cacheLayers.length,
114
+ displayLayerCount: displayLayers.length,
115
+ cachePrimitiveCount: cachePrimitives.length,
116
+ highlightGroupCount: highlightGroups.length,
117
+ layerTileCount: layerTiles.length,
118
+ selectedRoutePrimitiveCount: cachePrimitives.filter(
119
+ (primitive) => primitive.highlightState === 'selected'
120
+ ).length,
121
+ drillPrimitiveCount: cachePrimitives.filter((primitive) =>
122
+ DraftsmanBoardViewMetadataBuilder.#isDrillPrimitive(primitive)
123
+ ).length,
124
+ diagnosticCount: diagnostics.length
125
+ }
126
+
127
+ if (!Object.values(summary).some((value) => value > 0)) {
128
+ return undefined
129
+ }
130
+
131
+ return {
132
+ schema: DraftsmanBoardViewMetadataBuilder.SCHEMA_ID,
133
+ summary,
134
+ layerColors,
135
+ pcbParameters,
136
+ boardAssemblyViews,
137
+ boardFabricationViews,
138
+ boardProjections,
139
+ generatedGeometry,
140
+ cacheLayers,
141
+ displayLayers,
142
+ cachePrimitives,
143
+ highlightGroups,
144
+ layerTiles,
145
+ diagnostics
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Normalizes one board assembly view row.
151
+ * @param {Record<string, string>} fields Native fields.
152
+ * @returns {object}
153
+ */
154
+ static #boardAssemblyView(fields) {
155
+ return DraftsmanBoardViewMetadataBuilder.#stripEmpty({
156
+ id: fields.Id || fields.ID,
157
+ pageId: fields.PageId,
158
+ sourceDocumentName:
159
+ fields.SourceDocumentName || fields.SourceDocument,
160
+ variantName: fields.VariantName || fields.AssemblyVariant,
161
+ layerSet: DraftsmanBoardViewMetadataBuilder.#list(fields.LayerSet),
162
+ fields
163
+ })
164
+ }
165
+
166
+ /**
167
+ * Extracts generated board-view geometry descriptors from pages.
168
+ * @param {string} text Decoded text.
169
+ * @param {object[]} pages Parsed pages.
170
+ * @returns {object[]}
171
+ */
172
+ static #generatedGeometry(text, pages) {
173
+ return DraftsmanBoardViewMetadataBuilder.#pageBlocks(text).flatMap(
174
+ (pageBlock) =>
175
+ DraftsmanBoardViewMetadataBuilder.#tagFields(pageBlock.body, [
176
+ 'BoardView',
177
+ 'BoardAssemblyView',
178
+ 'BoardFabricationView'
179
+ ]).map((fields) =>
180
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
181
+ pageIndex:
182
+ pages.find((page) => page.id === pageBlock.id)
183
+ ?.index ?? pageBlock.index,
184
+ pageId: pageBlock.id,
185
+ id: fields.Id || fields.ID,
186
+ name: fields.Name || fields.Title,
187
+ geometrySource: fields.GeometrySource,
188
+ primitiveCount:
189
+ DraftsmanBoardViewMetadataBuilder.#integer(
190
+ fields.PrimitiveCount
191
+ ),
192
+ fields
193
+ })
194
+ )
195
+ )
196
+ }
197
+
198
+ /**
199
+ * Extracts cached PCB layer descriptors.
200
+ * @param {string} text Decoded text.
201
+ * @returns {object[]}
202
+ */
203
+ static #cacheLayers(text) {
204
+ return DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
205
+ 'BoardCacheLayer',
206
+ 'PcbCacheLayer',
207
+ 'PcbCachedLayer'
208
+ ]).map((fields) => {
209
+ const layerId = DraftsmanBoardViewMetadataBuilder.#integer(
210
+ fields.LayerId || fields.LayerID
211
+ )
212
+
213
+ return DraftsmanBoardViewMetadataBuilder.#stripEmpty({
214
+ id: fields.Id || fields.ID,
215
+ layerId,
216
+ layerKey: DraftsmanBoardViewMetadataBuilder.#layerKey(layerId),
217
+ layerName:
218
+ fields.LayerName || fields.DisplayName || fields.Name,
219
+ role: fields.Role || fields.LayerRole,
220
+ color: fields.Color,
221
+ primitiveCount: DraftsmanBoardViewMetadataBuilder.#integer(
222
+ fields.PrimitiveCount
223
+ ),
224
+ fields
225
+ })
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Extracts drawing display-layer descriptors.
231
+ * @param {string} text Decoded text.
232
+ * @returns {object[]}
233
+ */
234
+ static #displayLayers(text) {
235
+ return DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
236
+ 'BoardDisplayLayer',
237
+ 'PcbDisplayLayer',
238
+ 'PcbViewLayer'
239
+ ]).map((fields) =>
240
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
241
+ id: fields.Id || fields.ID,
242
+ cacheLayerId: fields.CacheLayerId || fields.CacheLayerID,
243
+ role: fields.Role || fields.LayerRole,
244
+ color: fields.Color,
245
+ visible: DraftsmanBoardViewMetadataBuilder.#boolean(
246
+ fields.Visible
247
+ ),
248
+ fields
249
+ })
250
+ )
251
+ }
252
+
253
+ /**
254
+ * Extracts cached PCB primitive descriptors from page-local board views.
255
+ * @param {string} text Decoded text.
256
+ * @param {object[]} pages Parsed pages.
257
+ * @returns {object[]}
258
+ */
259
+ static #cachePrimitives(text, pages) {
260
+ return DraftsmanBoardViewMetadataBuilder.#pageBlocks(text).flatMap(
261
+ (pageBlock) =>
262
+ DraftsmanBoardViewMetadataBuilder.#tagFields(pageBlock.body, [
263
+ 'BoardCachePrimitive',
264
+ 'PcbCachePrimitive',
265
+ 'CachedPrimitive'
266
+ ]).map((fields) => {
267
+ const layerId = DraftsmanBoardViewMetadataBuilder.#integer(
268
+ fields.LayerId || fields.LayerID
269
+ )
270
+
271
+ return DraftsmanBoardViewMetadataBuilder.#stripEmpty({
272
+ pageIndex:
273
+ pages.find((page) => page.id === pageBlock.id)
274
+ ?.index ?? pageBlock.index,
275
+ pageId: pageBlock.id,
276
+ id: fields.Id || fields.ID,
277
+ cacheLayerId:
278
+ fields.CacheLayerId || fields.CacheLayerID,
279
+ primitiveKind: fields.PrimitiveKind || fields.Kind,
280
+ layerId,
281
+ layerKey:
282
+ DraftsmanBoardViewMetadataBuilder.#layerKey(
283
+ layerId
284
+ ),
285
+ net: fields.Net || fields.NetName,
286
+ netClass: fields.NetClass,
287
+ component: fields.Component || fields.Designator,
288
+ padNumber: fields.PadNumber || fields.PinNumber,
289
+ routeGroup: fields.RouteGroup || fields.RouteId,
290
+ highlightState:
291
+ fields.HighlightState || fields.SelectionState,
292
+ holeKind: fields.HoleKind,
293
+ holePlating: fields.HolePlating,
294
+ holeRender: fields.HoleRender,
295
+ fields
296
+ })
297
+ })
298
+ )
299
+ }
300
+
301
+ /**
302
+ * Extracts route/net-class highlight groups used by cached board views.
303
+ * @param {string} text Decoded text.
304
+ * @returns {object[]}
305
+ */
306
+ static #highlightGroups(text) {
307
+ return DraftsmanBoardViewMetadataBuilder.#globalTagFields(text, [
308
+ 'BoardHighlightGroup',
309
+ 'PcbHighlightGroup',
310
+ 'BoardRouteHighlight'
311
+ ]).map((fields) =>
312
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
313
+ id: fields.Id || fields.ID,
314
+ name: fields.Name || fields.Title,
315
+ selectorKind: fields.SelectorKind || fields.Kind,
316
+ netClasses: DraftsmanBoardViewMetadataBuilder.#list(
317
+ fields.NetClasses || fields.NetClass
318
+ ),
319
+ differentialPairClasses:
320
+ DraftsmanBoardViewMetadataBuilder.#list(
321
+ fields.DifferentialPairClasses ||
322
+ fields.DifferentialPairClass
323
+ ),
324
+ differentialPairs: DraftsmanBoardViewMetadataBuilder.#list(
325
+ fields.DifferentialPairs || fields.DifferentialPair
326
+ ),
327
+ nets: DraftsmanBoardViewMetadataBuilder.#list(
328
+ fields.Nets || fields.NetNames || fields.Net
329
+ ),
330
+ highlightColor: fields.HighlightColor || fields.Color,
331
+ contextColor: fields.ContextColor,
332
+ minimumRoutedLength:
333
+ fields.MinimumRoutedLength || fields.RoutedLengthMinimum,
334
+ connectedRouteOnly: DraftsmanBoardViewMetadataBuilder.#boolean(
335
+ fields.ConnectedRouteOnly
336
+ ),
337
+ targetFillRatio: DraftsmanBoardViewMetadataBuilder.#number(
338
+ fields.TargetFillRatio
339
+ ),
340
+ tileSpacing: DraftsmanBoardViewMetadataBuilder.#number(
341
+ fields.TileSpacing
342
+ ),
343
+ layerSet: DraftsmanBoardViewMetadataBuilder.#list(
344
+ fields.LayerSet || fields.LayerIds
345
+ ),
346
+ fields
347
+ })
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Extracts page-local board layer tile descriptors.
353
+ * @param {string} text Decoded text.
354
+ * @param {object[]} pages Parsed pages.
355
+ * @returns {object[]}
356
+ */
357
+ static #layerTiles(text, pages) {
358
+ return DraftsmanBoardViewMetadataBuilder.#pageBlocks(text).flatMap(
359
+ (pageBlock) =>
360
+ DraftsmanBoardViewMetadataBuilder.#tagFields(pageBlock.body, [
361
+ 'BoardLayerTile',
362
+ 'PcbLayerTile',
363
+ 'BoardRouteTile'
364
+ ]).map((fields) => {
365
+ const layerId = DraftsmanBoardViewMetadataBuilder.#integer(
366
+ fields.LayerId || fields.LayerID
367
+ )
368
+
369
+ return DraftsmanBoardViewMetadataBuilder.#stripEmpty({
370
+ pageIndex:
371
+ pages.find((page) => page.id === pageBlock.id)
372
+ ?.index ?? pageBlock.index,
373
+ pageId: pageBlock.id,
374
+ id: fields.Id || fields.ID,
375
+ highlightGroupId:
376
+ fields.HighlightGroupId || fields.HighlightGroupID,
377
+ layerId,
378
+ layerKey:
379
+ DraftsmanBoardViewMetadataBuilder.#layerKey(
380
+ layerId
381
+ ),
382
+ layerName:
383
+ fields.LayerName ||
384
+ fields.DisplayName ||
385
+ fields.Name,
386
+ row: DraftsmanBoardViewMetadataBuilder.#integer(
387
+ fields.Row
388
+ ),
389
+ column: DraftsmanBoardViewMetadataBuilder.#integer(
390
+ fields.Column
391
+ ),
392
+ x: DraftsmanBoardViewMetadataBuilder.#number(fields.X),
393
+ y: DraftsmanBoardViewMetadataBuilder.#number(fields.Y),
394
+ width: DraftsmanBoardViewMetadataBuilder.#number(
395
+ fields.Width
396
+ ),
397
+ height: DraftsmanBoardViewMetadataBuilder.#number(
398
+ fields.Height
399
+ ),
400
+ scale: DraftsmanBoardViewMetadataBuilder.#number(
401
+ fields.Scale
402
+ ),
403
+ fields
404
+ })
405
+ })
406
+ )
407
+ }
408
+
409
+ /**
410
+ * Builds preservation diagnostics for unresolved cache references.
411
+ * @param {{ cacheLayers: object[], displayLayers: object[], cachePrimitives: object[], highlightGroups: object[], layerTiles: object[] }} input Parsed cache sections.
412
+ * @returns {object[]}
413
+ */
414
+ static #diagnostics(input) {
415
+ const diagnostics = []
416
+ const cacheLayerIds = new Set(
417
+ input.cacheLayers.map((layer) => layer.id).filter(Boolean)
418
+ )
419
+ const highlightGroupIds = new Set(
420
+ input.highlightGroups.map((group) => group.id).filter(Boolean)
421
+ )
422
+
423
+ for (const displayLayer of input.displayLayers) {
424
+ if (
425
+ !displayLayer.cacheLayerId ||
426
+ cacheLayerIds.has(displayLayer.cacheLayerId)
427
+ ) {
428
+ continue
429
+ }
430
+
431
+ diagnostics.push({
432
+ code: 'draftsman.board-view-cache.unresolved-display-layer-cache',
433
+ severity: 'warning',
434
+ message:
435
+ 'Display-layer metadata references an unknown cached PCB layer.',
436
+ displayLayerId: displayLayer.id,
437
+ cacheLayerId: displayLayer.cacheLayerId
438
+ })
439
+ }
440
+
441
+ for (const primitive of input.cachePrimitives) {
442
+ if (
443
+ !primitive.cacheLayerId ||
444
+ cacheLayerIds.has(primitive.cacheLayerId)
445
+ ) {
446
+ continue
447
+ }
448
+
449
+ diagnostics.push({
450
+ code: 'draftsman.board-view-cache.unresolved-primitive-cache',
451
+ severity: 'warning',
452
+ message:
453
+ 'Cached primitive metadata references an unknown cached PCB layer.',
454
+ primitiveId: primitive.id,
455
+ cacheLayerId: primitive.cacheLayerId
456
+ })
457
+ }
458
+
459
+ for (const tile of input.layerTiles) {
460
+ if (
461
+ !tile.highlightGroupId ||
462
+ highlightGroupIds.has(tile.highlightGroupId)
463
+ ) {
464
+ continue
465
+ }
466
+
467
+ diagnostics.push({
468
+ code: 'draftsman.board-view-cache.unresolved-layer-tile-highlight-group',
469
+ severity: 'warning',
470
+ message:
471
+ 'Layer tile metadata references an unknown highlight group.',
472
+ layerTileId: tile.id,
473
+ highlightGroupId: tile.highlightGroupId
474
+ })
475
+ }
476
+
477
+ return diagnostics
478
+ }
479
+
480
+ /**
481
+ * Extracts page ids and raw bodies for sidecar scans.
482
+ * @param {string} text Decoded text.
483
+ * @returns {{ index: number, id: string, body: string }[]}
484
+ */
485
+ static #pageBlocks(text) {
486
+ const blocks = []
487
+ const pagePattern =
488
+ /<Page\b([^>]*)>([\s\S]*?)<\/Page>|<Page\b([^>]*)\/>/giu
489
+ let match = pagePattern.exec(text || '')
490
+ while (match) {
491
+ const fields = DraftsmanBoardViewMetadataBuilder.#attributes(
492
+ match[1] || match[3] || ''
493
+ )
494
+ blocks.push({
495
+ index: blocks.length,
496
+ id: fields.Id || fields.ID || '',
497
+ body: match[2] || ''
498
+ })
499
+ match = pagePattern.exec(text || '')
500
+ }
501
+ return blocks
502
+ }
503
+
504
+ /**
505
+ * Extracts tag fields from a full container while ignoring page-contained
506
+ * copies of the same tags.
507
+ * @param {string} text Container text.
508
+ * @param {string[]} tagNames Tag names.
509
+ * @returns {Record<string, string>[]}
510
+ */
511
+ static #globalTagFields(text, tagNames) {
512
+ const stripped = String(text || '').replace(
513
+ /<Page\b[^>]*>[\s\S]*?<\/Page>|<Page\b[^>]*\/>/giu,
514
+ ''
515
+ )
516
+ return DraftsmanBoardViewMetadataBuilder.#tagFields(stripped, tagNames)
517
+ }
518
+
519
+ /**
520
+ * Extracts attributes from matching XML-like tags.
521
+ * @param {string} body Text body.
522
+ * @param {string[]} tagNames Tag names.
523
+ * @returns {Record<string, string>[]}
524
+ */
525
+ static #tagFields(body, tagNames) {
526
+ const tags = tagNames
527
+ .map((tagName) => tagName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
528
+ .join('|')
529
+ const pattern = new RegExp(
530
+ '<\\s*(' +
531
+ tags +
532
+ ')\\b([^>]*)>([\\s\\S]*?)<\\/\\s*\\1\\s*>|<\\s*(' +
533
+ tags +
534
+ ')\\b([^>]*)\\/>',
535
+ 'giu'
536
+ )
537
+ const rows = []
538
+ let match = pattern.exec(body || '')
539
+ while (match) {
540
+ const attributes = DraftsmanBoardViewMetadataBuilder.#attributes(
541
+ match[2] || match[5] || ''
542
+ )
543
+ const text = String(match[3] || '').trim()
544
+ rows.push(
545
+ DraftsmanBoardViewMetadataBuilder.#stripEmpty({
546
+ ...attributes,
547
+ value: attributes.Value || text || undefined
548
+ })
549
+ )
550
+ match = pattern.exec(body || '')
551
+ }
552
+ return rows
553
+ }
554
+
555
+ /**
556
+ * Parses XML-like attributes.
557
+ * @param {string} text Raw attribute text.
558
+ * @returns {Record<string, string>}
559
+ */
560
+ static #attributes(text) {
561
+ const fields = {}
562
+ const pattern = /([A-Za-z0-9_.:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu
563
+ let match = pattern.exec(text || '')
564
+ while (match) {
565
+ fields[match[1]] = match[3] ?? match[4] ?? ''
566
+ match = pattern.exec(text || '')
567
+ }
568
+ return fields
569
+ }
570
+
571
+ /**
572
+ * Parses a numeric value.
573
+ * @param {string | undefined} value Raw value.
574
+ * @returns {number | undefined}
575
+ */
576
+ static #number(value) {
577
+ const parsed = Number.parseFloat(String(value || '').trim())
578
+ return Number.isFinite(parsed) ? parsed : undefined
579
+ }
580
+
581
+ /**
582
+ * Parses an integer value.
583
+ * @param {string | undefined} value Raw value.
584
+ * @returns {number | undefined}
585
+ */
586
+ static #integer(value) {
587
+ const parsed = Number.parseInt(String(value || '').trim(), 10)
588
+ return Number.isFinite(parsed) ? parsed : undefined
589
+ }
590
+
591
+ /**
592
+ * Parses an optional boolean value.
593
+ * @param {string | undefined} value Raw value.
594
+ * @returns {boolean | undefined}
595
+ */
596
+ static #boolean(value) {
597
+ const normalized = String(value ?? '')
598
+ .trim()
599
+ .toLowerCase()
600
+ if (!normalized) return undefined
601
+ return ['true', 't', '1', 'yes'].includes(normalized)
602
+ }
603
+
604
+ /**
605
+ * Splits a comma/semicolon list.
606
+ * @param {string | undefined} value Raw list value.
607
+ * @returns {string[] | undefined}
608
+ */
609
+ static #list(value) {
610
+ const items = String(value || '')
611
+ .split(/[;,]/u)
612
+ .map((item) => item.trim())
613
+ .filter(Boolean)
614
+ return items.length ? items : undefined
615
+ }
616
+
617
+ /**
618
+ * Builds a stable layer key.
619
+ * @param {number | undefined} layerId Numeric layer id.
620
+ * @returns {string | undefined}
621
+ */
622
+ static #layerKey(layerId) {
623
+ return Number.isFinite(layerId) ? 'L' + layerId : undefined
624
+ }
625
+
626
+ /**
627
+ * Returns true when a cached primitive should be counted as drill-related.
628
+ * @param {object} primitive Cached primitive.
629
+ * @returns {boolean}
630
+ */
631
+ static #isDrillPrimitive(primitive) {
632
+ return (
633
+ Boolean(primitive.holeKind) ||
634
+ /(?:drill|hole|via)/iu.test(String(primitive.primitiveKind || ''))
635
+ )
636
+ }
637
+
638
+ /**
639
+ * Removes empty values from an object.
640
+ * @param {Record<string, unknown>} object Raw object.
641
+ * @returns {object}
642
+ */
643
+ static #stripEmpty(object) {
644
+ return Object.fromEntries(
645
+ Object.entries(object).filter(
646
+ ([, value]) =>
647
+ value !== undefined &&
648
+ value !== '' &&
649
+ (!Array.isArray(value) || value.length > 0)
650
+ )
651
+ )
652
+ }
653
+ }