circuitjson-toolkit 1.0.16 → 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 (155) hide show
  1. package/AGENTS.md +15 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +267 -107
  4. package/docs/api.md +501 -96
  5. package/docs/capabilities.md +70 -0
  6. package/docs/migration/behaviors.md +45 -0
  7. package/docs/migration/parser.md +60 -0
  8. package/docs/migration/renderers.md +515 -0
  9. package/docs/migration/root.md +740 -0
  10. package/docs/migration.md +120 -0
  11. package/docs/model-format.md +175 -57
  12. package/docs/provenance.md +206 -0
  13. package/docs/release-notes-v1.1.0.md +154 -0
  14. package/docs/testing.md +117 -7
  15. package/package.json +31 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +997 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +623 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -0,0 +1,429 @@
1
+ import { CircuitJsonDocumentContext } from '../context/CircuitJsonDocumentContext.mjs'
2
+ import { PcbPrimitivePreparation } from '../context/PcbPrimitivePreparation.mjs'
3
+ import { ToolkitDiagnostic } from '../contracts/ToolkitDiagnostic.mjs'
4
+ import { CircuitJsonPcbPrimitiveFields } from '../CircuitJsonPcbPrimitiveFields.mjs'
5
+ import { CanonicalRenderOptions } from './CanonicalRenderOptions.mjs'
6
+
7
+ const COPPER_KINDS = new Set([
8
+ 'breakout-point',
9
+ 'copper-text',
10
+ 'pad',
11
+ 'route-hint',
12
+ 'thermal-spoke',
13
+ 'track',
14
+ 'via',
15
+ 'zone'
16
+ ])
17
+ const VIRTUAL_LAYER_ORDER = [
18
+ 'top_silkscreen',
19
+ 'bottom_silkscreen',
20
+ 'top_fabrication',
21
+ 'bottom_fabrication',
22
+ 'top_courtyard',
23
+ 'bottom_courtyard',
24
+ 'top_soldermask',
25
+ 'bottom_soldermask',
26
+ 'top_paste',
27
+ 'bottom_paste',
28
+ 'keepouts',
29
+ 'cutouts',
30
+ 'panel',
31
+ 'breakout_points',
32
+ 'diagnostics',
33
+ 'groups',
34
+ 'anchor_offsets',
35
+ 'trace_lengths',
36
+ 'ratsnest'
37
+ ]
38
+ const VIRTUAL_LAYER_IDS = new Set(VIRTUAL_LAYER_ORDER)
39
+ const PREPARED_PLANS = new WeakSet()
40
+
41
+ /**
42
+ * Prepares one reusable CircuitJSON PCB primitive model and layer selection.
43
+ */
44
+ export class PcbRenderPlan {
45
+ /**
46
+ * Prepares a render plan from any canonical DocumentInput.
47
+ * @param {unknown} document DocumentResult, CircuitJSON model, or context.
48
+ * @param {Record<string, any>} [options] Canonical PCB render options.
49
+ * @returns {Record<string, any>} Prepared PCB render plan.
50
+ */
51
+ static prepare(document, options = {}) {
52
+ const normalized = CanonicalRenderOptions.normalize(options, {
53
+ layers: true,
54
+ side: true,
55
+ svg: true
56
+ })
57
+ CanonicalRenderOptions.requireCanonicalFidelity(normalized.fidelity)
58
+ const context = CircuitJsonDocumentContext.prepare(document)
59
+ const model = PcbPrimitivePreparation.prepareComplete(context)
60
+ const availableLayers = PcbRenderPlan.#layers(model)
61
+ const byId = new Map(availableLayers.map((layer) => [layer.id, layer]))
62
+ const selectedIds = normalized.layers
63
+ if (selectedIds) {
64
+ const missing = selectedIds.filter((id) => !byId.has(id))
65
+ if (missing.length) {
66
+ throw CanonicalRenderOptions.error(
67
+ `Unknown render layers: ${missing.join(', ')}.`
68
+ )
69
+ }
70
+ const wrongSide = selectedIds.filter((id) => {
71
+ const side = byId.get(id).side
72
+ return side === 'top' || side === 'bottom'
73
+ ? side !== normalized.side
74
+ : false
75
+ })
76
+ if (wrongSide.length) {
77
+ throw CanonicalRenderOptions.error(
78
+ `Render layers do not belong to side ${normalized.side}: ${wrongSide.join(', ')}.`
79
+ )
80
+ }
81
+ }
82
+ const layers = (
83
+ selectedIds ||
84
+ PcbRenderPlan.#defaultLayerIds(availableLayers, normalized.side)
85
+ ).map((id) => byId.get(id))
86
+ const statistics = context.statistics
87
+ const diagnostics = (model.diagnostics || []).map((row) =>
88
+ ToolkitDiagnostic.create({
89
+ code: row.code || row.type || row.id || 'PCB_RENDER_DIAGNOSTIC',
90
+ severity: row.severity || 'warning',
91
+ message: row.message || '',
92
+ source: context.source.fileName || '',
93
+ details: row
94
+ })
95
+ )
96
+ const plan = {
97
+ schema: 'ecad-toolkit.pcb-render-plan.v1',
98
+ model,
99
+ side: normalized.side,
100
+ fidelity: normalized.fidelity,
101
+ svg: normalized.svg,
102
+ layers,
103
+ selectedLayerIds: selectedIds ? [...selectedIds] : null,
104
+ diagnostics,
105
+ statistics: {
106
+ validationPasses: statistics.validationPasses,
107
+ primitiveBuilds:
108
+ statistics.derivedBuilds['render:pcb-primitives-v1'] || 0
109
+ }
110
+ }
111
+ PcbRenderPlan.#freeze(plan)
112
+ PREPARED_PLANS.add(plan)
113
+ return plan
114
+ }
115
+
116
+ /**
117
+ * Requires a genuine immutable plan created by this module.
118
+ * @param {unknown} plan Prepared-plan candidate.
119
+ * @returns {Record<string, any>} Branded render plan.
120
+ * @internal
121
+ */
122
+ static requirePrepared(plan) {
123
+ if (!plan || typeof plan !== 'object' || !PREPARED_PLANS.has(plan)) {
124
+ throw CanonicalRenderOptions.error(
125
+ 'Prepared PCB rendering requires an immutable render plan.'
126
+ )
127
+ }
128
+ return plan
129
+ }
130
+
131
+ /**
132
+ * Returns a filtered primitive-model view for selected canonical layers.
133
+ * @param {Record<string, any>} plan Prepared render plan.
134
+ * @param {string[] | null} [layerIds] Canonical layer ids.
135
+ * @returns {Record<string, any>} Full or layer-filtered primitive model.
136
+ */
137
+ static modelForLayers(plan, layerIds = plan.selectedLayerIds) {
138
+ PcbRenderPlan.requirePrepared(plan)
139
+ if (!layerIds) return plan.model
140
+ const ids = new Set(layerIds)
141
+ const descriptors = new Map(
142
+ plan.layers.map((layer) => [layer.id, layer])
143
+ )
144
+ const selected = layerIds
145
+ .map((id) => descriptors.get(id))
146
+ .filter(Boolean)
147
+ const copperLayers = new Set(
148
+ selected
149
+ .filter((layer) => layer.type === 'copper')
150
+ .map((layer) => layer.sourceLayerId)
151
+ )
152
+ const virtualLayers = new Set(
153
+ selected
154
+ .filter((layer) => layer.type !== 'copper')
155
+ .map((layer) => layer.id)
156
+ )
157
+ const primitives = (plan.model.primitives || []).filter(
158
+ (primitive) =>
159
+ primitive.kind === 'board' ||
160
+ (COPPER_KINDS.has(primitive.kind) &&
161
+ (copperLayers.has(primitive.layer) ||
162
+ (primitive.kind === 'via' &&
163
+ !primitive.side &&
164
+ PcbRenderPlan.#viaTouchesLayers(
165
+ primitive,
166
+ copperLayers,
167
+ plan.model.layers || []
168
+ )))) ||
169
+ virtualLayers.has(
170
+ PcbRenderPlan.#primitiveVirtualLayerId(primitive)
171
+ )
172
+ )
173
+ return {
174
+ ...plan.model,
175
+ layers: (plan.model.layers || []).filter((layer) =>
176
+ selected.some(
177
+ (candidate) =>
178
+ candidate.type === 'copper' &&
179
+ candidate.sourceLayerId === layer.id
180
+ )
181
+ ),
182
+ virtualLayers: selected.filter((layer) => layer.type !== 'copper'),
183
+ components: [],
184
+ primitives,
185
+ anchors: [],
186
+ diagnostics: ids.has('diagnostics')
187
+ ? plan.model.diagnostics || []
188
+ : [],
189
+ airwires: ids.has('ratsnest') ? plan.model.airwires || [] : [],
190
+ traceLengths: ids.has('trace_lengths')
191
+ ? plan.model.traceLengths || []
192
+ : [],
193
+ groups: ids.has('groups') ? plan.model.groups || [] : [],
194
+ anchorOffsets: ids.has('anchor_offsets')
195
+ ? plan.model.anchorOffsets || []
196
+ : []
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Builds canonical physical and virtual layer descriptors.
202
+ * @param {Record<string, any>} model Primitive model.
203
+ * @returns {object[]} Canonical layer descriptors.
204
+ */
205
+ static #layers(model) {
206
+ const physical = (model.layers || []).map((layer) => ({
207
+ ...layer,
208
+ id: `${layer.id}_copper`,
209
+ key: `${layer.id}_copper`,
210
+ sourceLayerId: layer.id,
211
+ type: 'copper'
212
+ }))
213
+ const reservedIds = new Set(physical.map((layer) => layer.id))
214
+ const byId = new Map()
215
+ for (const layer of model.virtualLayers || []) {
216
+ const id = String(layer.id || layer.key || '')
217
+ if (
218
+ !VIRTUAL_LAYER_IDS.has(id) ||
219
+ reservedIds.has(id) ||
220
+ byId.has(id)
221
+ ) {
222
+ continue
223
+ }
224
+ byId.set(id, {
225
+ ...layer,
226
+ id,
227
+ key: id,
228
+ side: PcbRenderPlan.#layerSide(id) || layer.side,
229
+ sourceLayerId: id
230
+ })
231
+ }
232
+ for (const primitive of model.primitives || []) {
233
+ const id = PcbRenderPlan.#primitiveVirtualLayerId(primitive)
234
+ if (
235
+ !VIRTUAL_LAYER_IDS.has(id) ||
236
+ reservedIds.has(id) ||
237
+ byId.has(id)
238
+ ) {
239
+ continue
240
+ }
241
+ byId.set(id, {
242
+ id,
243
+ key: id,
244
+ layer: id,
245
+ name: id,
246
+ side: PcbRenderPlan.#layerSide(id),
247
+ type: 'drawing',
248
+ sourceFormat: 'circuitjson',
249
+ sourceLayerId: id
250
+ })
251
+ }
252
+ const virtual = [
253
+ ...VIRTUAL_LAYER_ORDER.filter((id) => byId.has(id)).map((id) =>
254
+ byId.get(id)
255
+ ),
256
+ ...[...byId]
257
+ .filter(([id]) => !VIRTUAL_LAYER_ORDER.includes(id))
258
+ .sort(([left], [right]) =>
259
+ left < right ? -1 : left > right ? 1 : 0
260
+ )
261
+ .map(([_id, layer]) => layer)
262
+ ]
263
+ return [...physical, ...virtual]
264
+ }
265
+
266
+ /**
267
+ * Resolves a canonical virtual layer id from primitive kind and side.
268
+ * @param {object} primitive PCB primitive.
269
+ * @returns {string} Canonical virtual layer id or an empty string.
270
+ */
271
+ static #primitiveVirtualLayerId(primitive) {
272
+ const kind = String(primitive.kind || '')
273
+ if (
274
+ ['silkscreen', 'silkscreen_line', 'silkscreen_text'].includes(kind)
275
+ ) {
276
+ return `${PcbRenderPlan.#primitiveSide(primitive)}_silkscreen`
277
+ }
278
+ if (['fabrication', 'note', 'dimension'].includes(kind)) {
279
+ return `${PcbRenderPlan.#primitiveSide(primitive)}_fabrication`
280
+ }
281
+ if (kind === 'courtyard') {
282
+ return `${PcbRenderPlan.#primitiveSide(primitive)}_courtyard`
283
+ }
284
+ if (kind === 'solder-mask') {
285
+ return `${PcbRenderPlan.#primitiveSide(primitive)}_soldermask`
286
+ }
287
+ if (kind === 'solder-paste') {
288
+ return `${PcbRenderPlan.#primitiveSide(primitive)}_paste`
289
+ }
290
+ if (kind === 'keepout') return 'keepouts'
291
+ if (kind === 'cutout') return 'cutouts'
292
+ if (kind === 'panel') return 'panel'
293
+ if (kind === 'breakout-point') return 'breakout_points'
294
+ if (kind === 'board') return ''
295
+ const layer = String(primitive.layer || '')
296
+ return /^(?:top|bottom|inner\d+)$/u.test(layer) ? '' : layer
297
+ }
298
+
299
+ /**
300
+ * Resolves a primitive surface without changing the legacy primitive model.
301
+ * @param {object} primitive PCB primitive.
302
+ * @returns {'top' | 'bottom'} Canonical surface.
303
+ */
304
+ static #primitiveSide(primitive) {
305
+ if (primitive.side === 'bottom' || primitive.side === 'top') {
306
+ return primitive.side
307
+ }
308
+ return PcbRenderPlan.#layerSide(primitive.layer) || 'top'
309
+ }
310
+
311
+ /**
312
+ * Resolves a side from physical or canonical virtual layer ids.
313
+ * @param {unknown} layer Layer candidate.
314
+ * @returns {'top' | 'bottom' | ''} Surface or empty string.
315
+ */
316
+ static #layerSide(layer) {
317
+ const value = String(layer || '').toLowerCase()
318
+ if (value === 'top' || value.startsWith('top_')) return 'top'
319
+ if (value === 'bottom' || value.startsWith('bottom_')) return 'bottom'
320
+ return ''
321
+ }
322
+
323
+ /**
324
+ * Resolves default layers compatible with one side.
325
+ * @param {object[]} layers Available layers.
326
+ * @param {'top' | 'bottom'} side Requested side.
327
+ * @returns {string[]} Default layer ids.
328
+ */
329
+ static #defaultLayerIds(layers, side) {
330
+ return layers
331
+ .filter(
332
+ (layer) =>
333
+ !['top', 'bottom'].includes(layer.side) ||
334
+ layer.side === side
335
+ )
336
+ .map((layer) => layer.id)
337
+ }
338
+
339
+ /**
340
+ * Returns whether a drilled primitive spans any selected copper layer.
341
+ * @param {object} primitive Via-like primitive.
342
+ * @param {Set<string>} selectedLayers Selected physical layer ids.
343
+ * @param {object[]} physicalLayers Ordered board copper layers.
344
+ * @returns {boolean} Whether the via belongs in the layer view.
345
+ */
346
+ static #viaTouchesLayers(primitive, selectedLayers, physicalLayers) {
347
+ if (!selectedLayers.size) return false
348
+ const source = primitive.sourceRoute || primitive.source || {}
349
+ const declared = Array.isArray(source.layers)
350
+ ? source.layers
351
+ .map(CircuitJsonPcbPrimitiveFields.layer)
352
+ .filter(Boolean)
353
+ : []
354
+ const from = CircuitJsonPcbPrimitiveFields.layer(
355
+ declared.length
356
+ ? declared[0]
357
+ : (source.from_layer ?? source.fromLayer)
358
+ )
359
+ const to = CircuitJsonPcbPrimitiveFields.layer(
360
+ declared.length
361
+ ? declared.at(-1)
362
+ : (source.to_layer ?? source.toLayer)
363
+ )
364
+ if (!from && !to) return true
365
+ const ordered = physicalLayers.map((layer) => String(layer.id || ''))
366
+ const start = ordered.indexOf(from || to)
367
+ const end = ordered.indexOf(to || from)
368
+ if (start < 0 || end < 0) {
369
+ return [from, to, ...declared].some((layer) =>
370
+ selectedLayers.has(layer)
371
+ )
372
+ }
373
+ const minimum = Math.min(start, end)
374
+ const maximum = Math.max(start, end)
375
+ for (let index = minimum; index <= maximum; index += 1) {
376
+ if (selectedLayers.has(ordered[index])) return true
377
+ }
378
+ return false
379
+ }
380
+
381
+ /**
382
+ * Deep-freezes one cyclic primitive graph before it enters context cache.
383
+ * @param {any} value Value to freeze.
384
+ * @returns {any} Frozen value.
385
+ */
386
+ static #freeze(value) {
387
+ if (!value || typeof value !== 'object') return value
388
+ const pending = [{ value, expanded: false }]
389
+ const visited = new WeakSet()
390
+ while (pending.length) {
391
+ const current = pending.pop()
392
+ if (current.expanded) {
393
+ Object.freeze(current.value)
394
+ continue
395
+ }
396
+ if (visited.has(current.value) || Object.isFrozen(current.value)) {
397
+ continue
398
+ }
399
+ visited.add(current.value)
400
+ pending.push({ value: current.value, expanded: true })
401
+ if (current.value instanceof Map) {
402
+ for (const [key, entry] of current.value) {
403
+ if (key && typeof key === 'object') {
404
+ pending.push({ value: key, expanded: false })
405
+ }
406
+ if (entry && typeof entry === 'object') {
407
+ pending.push({ value: entry, expanded: false })
408
+ }
409
+ }
410
+ continue
411
+ }
412
+ for (const descriptor of Object.values(
413
+ Object.getOwnPropertyDescriptors(current.value)
414
+ )) {
415
+ if (
416
+ 'value' in descriptor &&
417
+ descriptor.value &&
418
+ typeof descriptor.value === 'object'
419
+ ) {
420
+ pending.push({
421
+ value: descriptor.value,
422
+ expanded: false
423
+ })
424
+ }
425
+ }
426
+ }
427
+ return value
428
+ }
429
+ }