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,368 @@
1
+ import { CircuitJsonDocumentContext } from './context/CircuitJsonDocumentContext.mjs'
2
+ import { PcbPrimitivePreparation } from './context/PcbPrimitivePreparation.mjs'
3
+ import { CanonicalInteractionOptions } from './interaction/CanonicalInteractionOptions.mjs'
4
+ import { PcbBoundsSelectionModel } from './PcbBoundsSelectionModel.mjs'
5
+ import { PcbCandidateSelectionModel } from './PcbCandidateSelectionModel.mjs'
6
+ import { PcbDiagnosticFocusModel } from './PcbDiagnosticFocusModel.mjs'
7
+ import { PcbInteractionPrimitiveModel } from './PcbInteractionPrimitiveModel.mjs'
8
+
9
+ const PRIMITIVE_CACHE = ['pcb', 'interaction-primitives-v1']
10
+ const SPATIAL_CACHE = ['interaction', 'pcb-spatial-v2']
11
+
12
+ /**
13
+ * Reuses a broad-phase index while retaining exact CircuitJSON hit predicates.
14
+ */
15
+ export class PcbInteractionIndex {
16
+ #candidateQueries = 0
17
+ #context
18
+ #defaults
19
+ #diagnosticFocus = null
20
+ #exactTests = 0
21
+ #groups
22
+ #model
23
+ #primitivesByRecordId
24
+ #spatial
25
+
26
+ /**
27
+ * Creates one bound reusable interaction index.
28
+ * @param {unknown} document DocumentResult, CircuitJSON model, or context.
29
+ * @param {Record<string, any>} [options] Reusable visibility defaults.
30
+ * @returns {PcbInteractionIndex} Bound interaction index.
31
+ */
32
+ static create(document, options = {}) {
33
+ const defaults = CanonicalInteractionOptions.normalize(options)
34
+ const context = CircuitJsonDocumentContext.prepare(document)
35
+ const model = PcbPrimitivePreparation.prepareInteraction(context)
36
+ const preparedRecords = PcbInteractionIndex.#spatialRecords(model)
37
+ const spatial = PcbPrimitivePreparation.prepareSpatial(
38
+ context,
39
+ model,
40
+ () => preparedRecords.records
41
+ )
42
+ return new PcbInteractionIndex(
43
+ context,
44
+ model,
45
+ spatial,
46
+ preparedRecords.primitivesByRecordId,
47
+ defaults
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Creates one prepared interaction service instance.
53
+ * @param {CircuitJsonDocumentContext} context Prepared context.
54
+ * @param {Record<string, any>} model Primitive model.
55
+ * @param {PcbSpatialIndex} spatial Spatial index.
56
+ * @param {Map<string, object>} primitivesByRecordId Stable primitive lookup.
57
+ * @param {Record<string, any>} defaults Normalized defaults.
58
+ */
59
+ constructor(context, model, spatial, primitivesByRecordId, defaults) {
60
+ this.#context = context
61
+ this.#model = model
62
+ this.#spatial = spatial
63
+ this.#primitivesByRecordId = primitivesByRecordId
64
+ this.#defaults = defaults
65
+ this.#groups = model.groups || []
66
+ }
67
+
68
+ /**
69
+ * Returns ordered canonical hit records for one board-space point.
70
+ * @param {{ x: number, y: number }} point CircuitJSON millimeter point.
71
+ * @param {Record<string, any>} [options] Per-query visibility options.
72
+ * @returns {object[]} Clone-safe ordered hit records.
73
+ */
74
+ hitTest(point, options = {}) {
75
+ const normalizedPoint = CanonicalInteractionOptions.point(point)
76
+ const normalized = CanonicalInteractionOptions.normalize(
77
+ options,
78
+ this.#defaults
79
+ )
80
+ const records = this.#spatial.search(
81
+ PcbInteractionIndex.#candidateBounds(
82
+ normalizedPoint,
83
+ normalized.tolerance
84
+ )
85
+ )
86
+ this.#candidateQueries += 1
87
+ this.#exactTests += records.length
88
+ const exactRecords = records.map((record) => ({
89
+ recordId: record.id,
90
+ primitive: this.#primitivesByRecordId.get(record.id)
91
+ }))
92
+ return PcbInteractionPrimitiveModel.hitTestRecords(
93
+ exactRecords,
94
+ normalizedPoint,
95
+ normalized,
96
+ this.#groups
97
+ ).map((hit) =>
98
+ PcbInteractionIndex.#hitRecord(
99
+ hit,
100
+ this.#primitivesByRecordId.get(hit.recordId)
101
+ )
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Returns the highest-priority hit or null.
107
+ * @param {{ x: number, y: number }} point CircuitJSON millimeter point.
108
+ * @param {Record<string, any>} [options] Per-query visibility options.
109
+ * @returns {object | null} First hit.
110
+ */
111
+ pick(point, options = {}) {
112
+ return this.hitTest(point, options)[0] || null
113
+ }
114
+
115
+ /**
116
+ * Resolves canonical area selection from prepared primitive bounds.
117
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Board-space selection bounds.
118
+ * @param {Record<string, any>} [options] Per-selection visibility options.
119
+ * @returns {{ bounds: object | null, point: object | null, candidates: object[], selectedCandidate: object | null, componentKeys: string[], netNames: string[] }} Clone-safe selection result.
120
+ */
121
+ selectBounds(bounds, options = {}) {
122
+ const normalized = CanonicalInteractionOptions.normalize(
123
+ options,
124
+ this.#defaults
125
+ )
126
+ const normalizedBounds = PcbBoundsSelectionModel.normalizeBounds(bounds)
127
+ if (!normalizedBounds) {
128
+ return PcbBoundsSelectionModel.resolvePrimitives(
129
+ [],
130
+ bounds,
131
+ normalized
132
+ )
133
+ }
134
+ const primitives = this.#spatial
135
+ .search(normalizedBounds)
136
+ .map((record) => this.#primitivesByRecordId.get(record.id))
137
+ return PcbBoundsSelectionModel.resolvePrimitives(
138
+ primitives,
139
+ normalizedBounds,
140
+ normalized
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Alias for canonical area-selection terminology used by source toolkits.
146
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Board-space selection bounds.
147
+ * @param {Record<string, any>} [options] Per-selection visibility options.
148
+ * @returns {{ bounds: object | null, point: object | null, candidates: object[], selectedCandidate: object | null, componentKeys: string[], netNames: string[] }} Clone-safe selection result.
149
+ */
150
+ selectArea(bounds, options = {}) {
151
+ return this.selectBounds(bounds, options)
152
+ }
153
+
154
+ /**
155
+ * Resolves click/hover candidates and the legacy selected-candidate state.
156
+ * @param {{ x: number, y: number }} point CircuitJSON millimeter point.
157
+ * @param {Record<string, any>} [options] Per-query visibility options.
158
+ * @returns {{ point: { x: number, y: number }, candidates: object[], componentCandidate: object | null, netCandidate: object | null, selectedCandidate: object | null }} Clone-safe selection state.
159
+ */
160
+ selectionAt(point, options = {}) {
161
+ const normalizedPoint = CanonicalInteractionOptions.point(point)
162
+ const candidates = this.hitTest(normalizedPoint, options)
163
+ return {
164
+ point: normalizedPoint,
165
+ candidates,
166
+ componentCandidate:
167
+ PcbCandidateSelectionModel.componentCandidate(candidates),
168
+ netCandidate: PcbCandidateSelectionModel.netCandidate(candidates),
169
+ selectedCandidate:
170
+ PcbCandidateSelectionModel.selectedCandidate(candidates)
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Snaps a board-space point to the nearest prepared primitive anchor.
176
+ * @param {{ x: number, y: number }} point CircuitJSON millimeter point.
177
+ * @param {{ tolerance?: number }} [options] Snap tolerance options.
178
+ * @returns {{ snapped: boolean, point: { x: number, y: number } }} Clone-safe snap result.
179
+ */
180
+ snap(point, options = {}) {
181
+ const normalizedPoint = CanonicalInteractionOptions.point(point)
182
+ const normalized = CanonicalInteractionOptions.normalize(options, {
183
+ ...this.#defaults,
184
+ tolerance: 0
185
+ })
186
+ return PcbInteractionPrimitiveModel.resolveSnapPointFromModel(
187
+ this.#model,
188
+ normalizedPoint,
189
+ { tolerance: normalized.tolerance }
190
+ )
191
+ }
192
+
193
+ /**
194
+ * Returns physical and virtual layer rows from the lazy complete model.
195
+ * @returns {{ physicalLayers: object[], virtualLayers: object[] }} Clone-safe layers.
196
+ */
197
+ resolveLayers() {
198
+ const model = PcbPrimitivePreparation.prepareComplete(this.#context)
199
+ return structuredClone({
200
+ physicalLayers: model.layers,
201
+ virtualLayers: model.virtualLayers || []
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Resolves one diagnostic id to its legacy board focus row.
207
+ * @param {string} diagnosticId Stable diagnostic id.
208
+ * @returns {object | null} Clone-safe diagnostic focus row.
209
+ */
210
+ resolveDiagnosticFocus(diagnosticId) {
211
+ if (typeof diagnosticId !== 'string') {
212
+ throw new TypeError('Diagnostic focus ids must be strings.')
213
+ }
214
+ if (!this.#diagnosticFocus) {
215
+ const model = PcbPrimitivePreparation.prepareComplete(this.#context)
216
+ this.#diagnosticFocus = PcbDiagnosticFocusModel.buildPrepared(
217
+ this.#context.model,
218
+ model
219
+ )
220
+ }
221
+ const focus = this.#diagnosticFocus.get(diagnosticId) || null
222
+ return focus ? structuredClone(focus) : null
223
+ }
224
+
225
+ /**
226
+ * Returns clone-safe construction and query counters.
227
+ * @returns {Record<string, number>} Interaction statistics.
228
+ */
229
+ get statistics() {
230
+ const statistics = this.#context.statistics
231
+ return {
232
+ validationPasses: statistics.validationPasses,
233
+ primitiveBuilds:
234
+ statistics.derivedBuilds[PRIMITIVE_CACHE.join(':')] || 0,
235
+ spatialIndexBuilds:
236
+ statistics.derivedBuilds[SPATIAL_CACHE.join(':')] || 0,
237
+ completePrimitiveBuilds:
238
+ statistics.derivedBuilds['render:pcb-primitives-v1'] || 0,
239
+ candidateQueries: this.#candidateQueries,
240
+ exactTests: this.#exactTests
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Builds stable plain records for the broad phase.
246
+ * @param {Record<string, any>} model Primitive model.
247
+ * @returns {{ records: object[], primitivesByRecordId: Map<string, object> }} Spatial records and exact primitive lookup.
248
+ */
249
+ static #spatialRecords(model) {
250
+ const records = []
251
+ const primitivesByRecordId = new Map()
252
+ for (const [ordinal, primitive] of (model.primitives || []).entries()) {
253
+ const bounds =
254
+ PcbInteractionPrimitiveModel.interactionBounds(primitive)
255
+ if (!bounds) continue
256
+ const id = `pcb-primitive:${ordinal}`
257
+ records.push({
258
+ id,
259
+ bounds
260
+ })
261
+ primitivesByRecordId.set(id, primitive)
262
+ }
263
+ return { records, primitivesByRecordId }
264
+ }
265
+
266
+ /**
267
+ * Expands point queries enough to cover exact local-axis tolerance after
268
+ * arbitrary two-dimensional rotation plus floating-point boundary drift.
269
+ * @param {{ x: number, y: number }} point Normalized query point.
270
+ * @param {number} tolerance Exact narrow-phase tolerance.
271
+ * @returns {number} Conservative broad-phase tolerance.
272
+ */
273
+ static #candidateTolerance(point, tolerance) {
274
+ const magnitude = Math.max(
275
+ 1,
276
+ Math.abs(point.x),
277
+ Math.abs(point.y),
278
+ tolerance
279
+ )
280
+ return tolerance * Math.SQRT2 + Number.EPSILON * magnitude * 16
281
+ }
282
+
283
+ /**
284
+ * Builds a finite query rectangle without passing the conservative
285
+ * expansion through the public spatial-index tolerance limit.
286
+ * @param {{ x: number, y: number }} point Normalized query point.
287
+ * @param {number} tolerance Exact narrow-phase tolerance.
288
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Conservative finite query bounds.
289
+ */
290
+ static #candidateBounds(point, tolerance) {
291
+ const expansion = PcbInteractionIndex.#candidateTolerance(
292
+ point,
293
+ tolerance
294
+ )
295
+ return {
296
+ minX: Math.max(-Number.MAX_VALUE, point.x - expansion),
297
+ minY: Math.max(-Number.MAX_VALUE, point.y - expansion),
298
+ maxX: Math.min(Number.MAX_VALUE, point.x + expansion),
299
+ maxY: Math.min(Number.MAX_VALUE, point.y + expansion)
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Converts one legacy exact hit to the common clone-safe shape.
305
+ * @param {object} hit Legacy exact hit.
306
+ * @param {object | undefined} primitive Matching primitive.
307
+ * @returns {object} Canonical hit record.
308
+ */
309
+ static #hitRecord(hit, primitive) {
310
+ const source = primitive?.source || primitive?.sourceRoute || {}
311
+ const elementType = String(source.type || '')
312
+ const elementId = PcbInteractionIndex.#elementId(
313
+ source,
314
+ elementType,
315
+ primitive
316
+ )
317
+ const bounds = PcbInteractionPrimitiveModel.interactionBounds(
318
+ primitive
319
+ ) || {
320
+ minX: 0,
321
+ minY: 0,
322
+ maxX: 0,
323
+ maxY: 0
324
+ }
325
+ return {
326
+ elementId,
327
+ primitiveId: String(primitive?.id || hit.id || ''),
328
+ kind: String(primitive?.kind || hit.kind || ''),
329
+ side: ['top', 'bottom'].includes(primitive?.side)
330
+ ? primitive.side
331
+ : null,
332
+ layerId: String(primitive?.layer || hit.layer || ''),
333
+ bounds: { ...bounds },
334
+ distance: Number(hit.distance),
335
+ componentId: String(
336
+ primitive?.componentId || hit.componentId || ''
337
+ ),
338
+ componentKey: String(
339
+ primitive?.componentKey || hit.componentKey || ''
340
+ ),
341
+ netName: String(primitive?.netName || hit.netName || ''),
342
+ groupIds: [...(primitive?.groupIds || hit.groupIds || [])],
343
+ source: {
344
+ format: 'circuitjson',
345
+ elementId,
346
+ elementType
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Resolves the stable owning CircuitJSON element id.
353
+ * @param {object} source Source element.
354
+ * @param {string} elementType Source type.
355
+ * @param {object | undefined} primitive Primitive.
356
+ * @returns {string} Stable element id.
357
+ */
358
+ static #elementId(source, elementType, primitive) {
359
+ const direct = source[elementType + '_id']
360
+ if (typeof direct === 'string' && direct) return direct
361
+ for (const [key, value] of Object.entries(source)) {
362
+ if (key.endsWith('_id') && typeof value === 'string' && value) {
363
+ return value
364
+ }
365
+ }
366
+ return String(primitive?.id || '')
367
+ }
368
+ }