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,343 @@
1
+ import { ComponentGrouping } from './ComponentGrouping.mjs'
2
+
3
+ /**
4
+ * Traverses canonical trace connectivity with stable bounded breadth-first work.
5
+ */
6
+ export class CircuitTraversal {
7
+ /**
8
+ * Prepares reusable trace and membership indexes without expanding edges.
9
+ * @param {Record<string, any>} netlist Canonical query netlist.
10
+ * @returns {{ tracesById: Map<string, object>, traceIdsByMembership: Map<string, string[]>, traceIdsByComponent: Map<string, string[]> }} Traversal graph.
11
+ */
12
+ static prepare(netlist) {
13
+ const tracesById = new Map()
14
+ const traceIdsByMembership = new Map()
15
+ const traceIdsByComponent = new Map()
16
+ for (const trace of netlist.traces || []) {
17
+ tracesById.set(trace.id, trace)
18
+ for (const key of CircuitTraversal.#membershipKeys(trace)) {
19
+ if (!traceIdsByMembership.has(key)) {
20
+ traceIdsByMembership.set(key, [])
21
+ }
22
+ traceIdsByMembership.get(key).push(trace.id)
23
+ }
24
+ for (const componentId of trace.sourceComponentIds || []) {
25
+ if (!traceIdsByComponent.has(componentId)) {
26
+ traceIdsByComponent.set(componentId, [])
27
+ }
28
+ traceIdsByComponent.get(componentId).push(trace.id)
29
+ }
30
+ }
31
+ for (const ids of [
32
+ ...traceIdsByMembership.values(),
33
+ ...traceIdsByComponent.values()
34
+ ]) {
35
+ ids.sort(ComponentGrouping.compareIds)
36
+ }
37
+ return { tracesById, traceIdsByMembership, traceIdsByComponent }
38
+ }
39
+
40
+ /**
41
+ * Traverses from one or more canonical source ids.
42
+ * @param {{ tracesById: Map<string, object>, traceIdsByMembership: Map<string, string[]>, traceIdsByComponent: Map<string, string[]> }} graph Prepared graph.
43
+ * @param {Record<string, string>} request Starting source ids.
44
+ * @param {{ maxDepth: number, maxResults: number }} options Bounds.
45
+ * @returns {object[]} Ordered connectivity records.
46
+ */
47
+ static trace(graph, request, options) {
48
+ const initial = CircuitTraversal.#initialTraceIds(
49
+ graph,
50
+ request,
51
+ options.maxResults
52
+ )
53
+ const queue = initial.map((id) => ({
54
+ id,
55
+ depth: 0,
56
+ path: [{ traceId: id, via: null }]
57
+ }))
58
+ const queued = new Set(initial)
59
+ const visited = new Set()
60
+ const expandedMemberships = new Set()
61
+ const results = []
62
+ let cursor = 0
63
+
64
+ while (cursor < queue.length && results.length < options.maxResults) {
65
+ const current = queue[cursor]
66
+ cursor += 1
67
+ if (visited.has(current.id)) continue
68
+ visited.add(current.id)
69
+ const trace = graph.tracesById.get(current.id)
70
+ if (!trace) continue
71
+ results.push({
72
+ ...structuredClone(trace),
73
+ depth: current.depth,
74
+ path: [...current.path]
75
+ })
76
+ if (results.length >= options.maxResults) break
77
+ if (current.depth >= options.maxDepth) continue
78
+
79
+ const sources = []
80
+ for (const key of CircuitTraversal.#membershipKeys(trace).sort(
81
+ ComponentGrouping.compareIds
82
+ )) {
83
+ if (expandedMemberships.has(key)) continue
84
+ expandedMemberships.add(key)
85
+ const ids = graph.traceIdsByMembership.get(key)
86
+ if (!ids) continue
87
+ sources.push({
88
+ key,
89
+ ids,
90
+ via: CircuitTraversal.#connector(key)
91
+ })
92
+ }
93
+ const capacity = options.maxResults - queue.length
94
+ for (const neighbor of CircuitTraversal.#takeSortedUnique(
95
+ sources,
96
+ capacity,
97
+ new Set([...visited, ...queued])
98
+ )) {
99
+ queued.add(neighbor.id)
100
+ queue.push({
101
+ id: neighbor.id,
102
+ depth: current.depth + 1,
103
+ path: [
104
+ ...current.path,
105
+ { traceId: neighbor.id, via: neighbor.via }
106
+ ]
107
+ })
108
+ }
109
+ }
110
+ return results
111
+ }
112
+
113
+ /**
114
+ * Resolves starting traces from direct selector indexes.
115
+ * @param {{ tracesById: Map<string, object>, traceIdsByMembership: Map<string, string[]>, traceIdsByComponent: Map<string, string[]> }} graph Prepared graph.
116
+ * @param {Record<string, string>} request Start request.
117
+ * @param {number} maxResults Maximum starting records.
118
+ * @returns {string[]} Stable starting trace ids.
119
+ */
120
+ static #initialTraceIds(graph, request, maxResults) {
121
+ const sources = []
122
+ if (
123
+ request.sourceTraceId &&
124
+ graph.tracesById.has(request.sourceTraceId)
125
+ ) {
126
+ sources.push({
127
+ key: 'trace',
128
+ ids: [request.sourceTraceId],
129
+ via: null
130
+ })
131
+ }
132
+ if (request.sourceComponentId) {
133
+ const ids = graph.traceIdsByComponent.get(request.sourceComponentId)
134
+ if (ids) {
135
+ sources.push({ key: 'component', ids, via: null })
136
+ }
137
+ }
138
+ if (request.sourcePortId) {
139
+ const ids = graph.traceIdsByMembership.get(
140
+ `port:${request.sourcePortId}`
141
+ )
142
+ if (ids) {
143
+ sources.push({ key: 'port', ids, via: null })
144
+ }
145
+ }
146
+ if (request.sourceNetId) {
147
+ const ids = graph.traceIdsByMembership.get(
148
+ `net:${request.sourceNetId}`
149
+ )
150
+ if (ids) {
151
+ sources.push({ key: 'net', ids, via: null })
152
+ }
153
+ }
154
+ return CircuitTraversal.#takeSortedUnique(sources, maxResults).map(
155
+ (row) => row.id
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Takes a bounded sorted union without materializing complete source lists.
161
+ * @param {{ key: string, ids: Iterable<string>, via: object | null }[]} sources Sorted id sources.
162
+ * @param {number} limit Maximum returned rows.
163
+ * @param {Set<string>} [excluded] Ids that must not be returned.
164
+ * @returns {{ id: string, via: object | null }[]} Stable unique rows.
165
+ */
166
+ static #takeSortedUnique(sources, limit, excluded = new Set()) {
167
+ if (limit <= 0) return []
168
+ const heap = sources
169
+ .map((source, index) =>
170
+ CircuitTraversal.#iteratorState(source, index)
171
+ )
172
+ .filter(Boolean)
173
+ CircuitTraversal.#heapify(heap)
174
+ const seen = new Set(excluded)
175
+ const result = []
176
+
177
+ while (heap.length && result.length < limit) {
178
+ const current = CircuitTraversal.#popHeap(heap)
179
+ const accepted = !seen.has(current.id)
180
+ if (accepted) {
181
+ seen.add(current.id)
182
+ result.push({ id: current.id, via: current.via })
183
+ if (result.length >= limit) break
184
+ }
185
+ const next = current.iterator.next()
186
+ if (!next.done) {
187
+ current.id = String(next.value)
188
+ CircuitTraversal.#pushHeap(heap, current)
189
+ }
190
+ }
191
+ return result
192
+ }
193
+
194
+ /**
195
+ * Opens one sorted source iterator and reads only its first id.
196
+ * @param {{ key: string, ids: Iterable<string>, via: object | null }} source Id source.
197
+ * @param {number} index Stable source index.
198
+ * @returns {object | null} Active iterator state.
199
+ */
200
+ static #iteratorState(source, index) {
201
+ const iterator = source.ids[Symbol.iterator]()
202
+ const first = iterator.next()
203
+ return first.done
204
+ ? null
205
+ : {
206
+ id: String(first.value),
207
+ index,
208
+ iterator,
209
+ key: source.key,
210
+ via: source.via
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Orders active iterator heads by id and deterministic connector source.
216
+ * @param {object} left Left iterator state.
217
+ * @param {object} right Right iterator state.
218
+ * @returns {number} Stable sort order.
219
+ */
220
+ static #compareIteratorStates(left, right) {
221
+ return (
222
+ ComponentGrouping.compareIds(left.id, right.id) ||
223
+ ComponentGrouping.compareIds(left.key, right.key) ||
224
+ left.index - right.index
225
+ )
226
+ }
227
+
228
+ /**
229
+ * Builds a minimum heap of active iterator heads in linear time.
230
+ * @param {object[]} heap Iterator state heap.
231
+ * @returns {void}
232
+ */
233
+ static #heapify(heap) {
234
+ for (
235
+ let index = Math.floor(heap.length / 2) - 1;
236
+ index >= 0;
237
+ index -= 1
238
+ ) {
239
+ CircuitTraversal.#siftDown(heap, index)
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Removes the smallest active iterator head.
245
+ * @param {object[]} heap Iterator state heap.
246
+ * @returns {object} Smallest iterator state.
247
+ */
248
+ static #popHeap(heap) {
249
+ const first = heap[0]
250
+ const last = heap.pop()
251
+ if (heap.length) {
252
+ heap[0] = last
253
+ CircuitTraversal.#siftDown(heap, 0)
254
+ }
255
+ return first
256
+ }
257
+
258
+ /**
259
+ * Adds one active iterator head while preserving heap order.
260
+ * @param {object[]} heap Iterator state heap.
261
+ * @param {object} state Iterator state.
262
+ * @returns {void}
263
+ */
264
+ static #pushHeap(heap, state) {
265
+ let index = heap.length
266
+ heap.push(state)
267
+ while (index > 0) {
268
+ const parentIndex = Math.floor((index - 1) / 2)
269
+ const parent = heap[parentIndex]
270
+ if (CircuitTraversal.#compareIteratorStates(parent, state) <= 0) {
271
+ break
272
+ }
273
+ heap[index] = parent
274
+ index = parentIndex
275
+ }
276
+ heap[index] = state
277
+ }
278
+
279
+ /**
280
+ * Restores minimum-heap order below one replaced iterator head.
281
+ * @param {object[]} heap Iterator state heap.
282
+ * @param {number} startIndex Replaced head index.
283
+ * @returns {void}
284
+ */
285
+ static #siftDown(heap, startIndex) {
286
+ const state = heap[startIndex]
287
+ let index = startIndex
288
+ while (true) {
289
+ const leftIndex = index * 2 + 1
290
+ if (leftIndex >= heap.length) break
291
+ const rightIndex = leftIndex + 1
292
+ let childIndex = leftIndex
293
+ if (
294
+ rightIndex < heap.length &&
295
+ CircuitTraversal.#compareIteratorStates(
296
+ heap[rightIndex],
297
+ heap[leftIndex]
298
+ ) < 0
299
+ ) {
300
+ childIndex = rightIndex
301
+ }
302
+ if (
303
+ CircuitTraversal.#compareIteratorStates(
304
+ heap[childIndex],
305
+ state
306
+ ) >= 0
307
+ ) {
308
+ break
309
+ }
310
+ heap[index] = heap[childIndex]
311
+ index = childIndex
312
+ }
313
+ heap[index] = state
314
+ }
315
+
316
+ /**
317
+ * Builds shared-membership keys for one trace.
318
+ * @param {Record<string, any>} trace Trace record.
319
+ * @returns {string[]} Membership keys.
320
+ */
321
+ static #membershipKeys(trace) {
322
+ return [
323
+ ...(trace.sourcePortIds || []).map((id) => `port:${id}`),
324
+ ...(trace.sourceNetIds || []).map((id) => `net:${id}`),
325
+ ...(trace.internalConnectionIds || []).map(
326
+ (id) => `internalConnection:${id}`
327
+ )
328
+ ]
329
+ }
330
+
331
+ /**
332
+ * Converts one membership key into a clone-safe path connector.
333
+ * @param {string} key Membership key.
334
+ * @returns {{ kind: 'port' | 'net' | 'internalConnection', id: string }} Connector record.
335
+ */
336
+ static #connector(key) {
337
+ const separator = key.indexOf(':')
338
+ return {
339
+ kind: key.slice(0, separator),
340
+ id: key.slice(separator + 1)
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,275 @@
1
+ const COMPONENT_VALUE_FIELDS_BY_TYPE = Object.freeze({
2
+ simple_battery: ['capacity'],
3
+ simple_capacitor: ['display_capacitance', 'capacitance'],
4
+ simple_crystal: ['frequency'],
5
+ simple_current_source: ['current'],
6
+ simple_fuse: ['current_rating_amps'],
7
+ simple_inductor: ['display_inductance', 'inductance'],
8
+ simple_potentiometer: ['display_max_resistance', 'max_resistance'],
9
+ simple_power_source: ['voltage'],
10
+ simple_resistor: ['display_resistance', 'resistance'],
11
+ simple_resonator: ['frequency'],
12
+ simple_voltage_source: ['voltage']
13
+ })
14
+ const COMPONENT_VALUE_FALLBACK_FIELDS = [
15
+ 'display_resistance',
16
+ 'display_capacitance',
17
+ 'display_inductance',
18
+ 'display_max_resistance',
19
+ 'resistance',
20
+ 'capacitance',
21
+ 'inductance',
22
+ 'frequency',
23
+ 'max_resistance',
24
+ 'voltage',
25
+ 'current',
26
+ 'capacity'
27
+ ]
28
+
29
+ /**
30
+ * Builds deterministic component and net records from prepared CircuitJSON indexes.
31
+ */
32
+ export class ComponentGrouping {
33
+ /**
34
+ * Builds source-component rows with their canonical source-port ids.
35
+ * @param {Record<string, any>} elementsIndex Prepared elements index.
36
+ * @param {Record<string, any>} relationsIndex Prepared relations index.
37
+ * @returns {object[]} Stable component records.
38
+ */
39
+ static components(elementsIndex, relationsIndex) {
40
+ const components =
41
+ elementsIndex.elementsByType.get('source_component') || []
42
+ const byComponent =
43
+ relationsIndex.relationsByField.get('source_component_id') ||
44
+ new Map()
45
+ return components
46
+ .map((component) => {
47
+ const id = String(component.source_component_id || '').trim()
48
+ const related = byComponent.get(id) || []
49
+ const pins = related
50
+ .filter((element) => element?.type === 'source_port')
51
+ .map((port) => ComponentGrouping.#pin(port))
52
+ .sort((left, right) =>
53
+ ComponentGrouping.compareIds(left.id, right.id)
54
+ )
55
+ return ComponentGrouping.#withoutEmpty({
56
+ id,
57
+ name: String(
58
+ component.display_name || component.name || id
59
+ ),
60
+ designator: String(component.name || id),
61
+ type: String(component.ftype || ''),
62
+ value: ComponentGrouping.#componentValue(component),
63
+ footprint: ComponentGrouping.#footprint(component, related),
64
+ mpn: ComponentGrouping.#mpn(component),
65
+ description: ComponentGrouping.#text(component.description),
66
+ doNotPopulate:
67
+ component.do_not_populate === true ||
68
+ component.exclude_from_bom === true,
69
+ pinIds: pins.map((pin) => pin.id),
70
+ pins
71
+ })
72
+ })
73
+ .filter((component) => component.id)
74
+ .sort((left, right) =>
75
+ ComponentGrouping.compareIds(left.id, right.id)
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Builds source-net rows with trace and port membership.
81
+ * @param {Record<string, any>} elementsIndex Prepared elements index.
82
+ * @param {object[]} traces Canonical trace records.
83
+ * @returns {object[]} Stable net records.
84
+ */
85
+ static nets(elementsIndex, traces) {
86
+ const nets = elementsIndex.elementsByType.get('source_net') || []
87
+ const tracesByNetId = new Map()
88
+ for (const trace of traces) {
89
+ for (const netId of trace.sourceNetIds) {
90
+ if (!tracesByNetId.has(netId)) {
91
+ tracesByNetId.set(netId, [])
92
+ }
93
+ tracesByNetId.get(netId).push(trace)
94
+ }
95
+ }
96
+ return nets
97
+ .map((net) => {
98
+ const id = String(net.source_net_id || '').trim()
99
+ const connected = tracesByNetId.get(id) || []
100
+ return {
101
+ id,
102
+ name: String(net.name || id),
103
+ traceIds: connected.map((trace) => trace.id),
104
+ portIds: [
105
+ ...new Set(
106
+ connected.flatMap((trace) => trace.sourcePortIds)
107
+ )
108
+ ].sort(ComponentGrouping.compareIds)
109
+ }
110
+ })
111
+ .filter((net) => net.id)
112
+ .sort((left, right) =>
113
+ ComponentGrouping.compareIds(left.id, right.id)
114
+ )
115
+ }
116
+
117
+ /**
118
+ * Compares stable ids without locale-dependent collation.
119
+ * @param {unknown} left Left id.
120
+ * @param {unknown} right Right id.
121
+ * @returns {number} Sort order.
122
+ */
123
+ static compareIds(left, right) {
124
+ const leftText = String(left || '')
125
+ const rightText = String(right || '')
126
+ return leftText < rightText ? -1 : leftText > rightText ? 1 : 0
127
+ }
128
+
129
+ /**
130
+ * Builds one canonical source-port record.
131
+ * @param {Record<string, any>} port Source port.
132
+ * @returns {object} Port record.
133
+ */
134
+ static #pin(port) {
135
+ return ComponentGrouping.#withoutEmpty({
136
+ id: String(port.source_port_id || '').trim(),
137
+ name: ComponentGrouping.#text(port.name),
138
+ pinNumber:
139
+ port.pin_number === undefined ? undefined : port.pin_number,
140
+ netIds: ComponentGrouping.#ids([
141
+ port.source_net_id,
142
+ port.connected_source_net_id,
143
+ port.source_net_ids,
144
+ port.connected_source_net_ids
145
+ ])
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Resolves the first linked PCB footprint before legacy source fields.
151
+ * @param {Record<string, any>} component Source component.
152
+ * @param {object[]} related Elements related by source-component id.
153
+ * @returns {string | undefined} Footprint name.
154
+ */
155
+ static #footprint(component, related) {
156
+ const pcbComponents = related
157
+ .filter((element) => element?.type === 'pcb_component')
158
+ .sort((left, right) =>
159
+ ComponentGrouping.compareIds(
160
+ left.pcb_component_id,
161
+ right.pcb_component_id
162
+ )
163
+ )
164
+ for (const pcbComponent of pcbComponents) {
165
+ const footprint = ComponentGrouping.#text(
166
+ pcbComponent.metadata?.kicad_footprint?.footprintName
167
+ )
168
+ if (footprint) return footprint
169
+ }
170
+ return ComponentGrouping.#text(
171
+ component.footprint || component.footprint_name
172
+ )
173
+ }
174
+
175
+ /**
176
+ * Resolves standard display and typed source-component values.
177
+ * @param {Record<string, any>} component Source component.
178
+ * @returns {string | undefined} Canonical display value.
179
+ */
180
+ static #componentValue(component) {
181
+ const typedFields =
182
+ COMPONENT_VALUE_FIELDS_BY_TYPE[String(component.ftype || '')] || []
183
+ const fields = [
184
+ 'display_value',
185
+ ...typedFields,
186
+ 'value',
187
+ ...COMPONENT_VALUE_FALLBACK_FIELDS
188
+ ]
189
+ for (const field of new Set(fields)) {
190
+ const value = ComponentGrouping.#scalarText(component[field])
191
+ if (value !== undefined) return value
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ /**
197
+ * Resolves the first source-native manufacturer/supplier part number.
198
+ * @param {Record<string, any>} component Source component.
199
+ * @returns {string | undefined} Part number.
200
+ */
201
+ static #mpn(component) {
202
+ const direct = ComponentGrouping.#text(
203
+ component.manufacturer_part_number ||
204
+ component.mpn ||
205
+ component.part_number
206
+ )
207
+ if (direct) return direct
208
+ const suppliers = component.supplier_part_numbers
209
+ if (!suppliers || typeof suppliers !== 'object') return undefined
210
+ for (const value of Object.values(suppliers)) {
211
+ const candidates = Array.isArray(value) ? value : [value]
212
+ for (const candidate of candidates) {
213
+ const text = ComponentGrouping.#text(candidate)
214
+ if (text) return text
215
+ }
216
+ }
217
+ return undefined
218
+ }
219
+
220
+ /**
221
+ * Flattens scalar/array relation ids into a stable unique list.
222
+ * @param {unknown[]} values Relation values.
223
+ * @returns {string[]} Stable ids.
224
+ */
225
+ static #ids(values) {
226
+ return [
227
+ ...new Set(
228
+ values
229
+ .flatMap((value) =>
230
+ Array.isArray(value) ? value : [value]
231
+ )
232
+ .map((value) => String(value || '').trim())
233
+ .filter(Boolean)
234
+ )
235
+ ].sort(ComponentGrouping.compareIds)
236
+ }
237
+
238
+ /**
239
+ * Normalizes optional text without executing caller coercion hooks.
240
+ * @param {unknown} value Text candidate.
241
+ * @returns {string | undefined} Normalized text.
242
+ */
243
+ static #text(value) {
244
+ return typeof value === 'string' && value.trim()
245
+ ? value.trim()
246
+ : undefined
247
+ }
248
+
249
+ /**
250
+ * Normalizes a string or finite numeric scalar for query matching.
251
+ * @param {unknown} value Scalar candidate.
252
+ * @returns {string | undefined} Normalized scalar text.
253
+ */
254
+ static #scalarText(value) {
255
+ if (typeof value === 'string') return ComponentGrouping.#text(value)
256
+ return typeof value === 'number' && Number.isFinite(value)
257
+ ? String(value)
258
+ : undefined
259
+ }
260
+
261
+ /**
262
+ * Omits undefined, empty-string, and false optional fields.
263
+ * @param {Record<string, any>} record Record candidate.
264
+ * @returns {Record<string, any>} Compact record.
265
+ */
266
+ static #withoutEmpty(record) {
267
+ return Object.fromEntries(
268
+ Object.entries(record).filter(([, value]) => {
269
+ if (value === undefined || value === '') return false
270
+ if (value === false) return false
271
+ return true
272
+ })
273
+ )
274
+ }
275
+ }