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
@@ -1,5 +1,8 @@
1
1
  import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
2
2
  import { CircuitJsonPcbPrimitiveBuilder } from './CircuitJsonPcbPrimitiveBuilder.mjs'
3
+ import { CircuitJsonDocumentContext } from './context/CircuitJsonDocumentContext.mjs'
4
+ import { PcbPrimitivePreparation } from './context/PcbPrimitivePreparation.mjs'
5
+ import { PcbInteractionBounds } from './interaction/PcbInteractionBounds.mjs'
3
6
 
4
7
  /**
5
8
  * Builds renderer-neutral PCB primitives for element-array board documents.
@@ -11,13 +14,17 @@ export class PcbInteractionPrimitiveModel {
11
14
  * @returns {{ bounds: object, layers: object[], virtualLayers: object[], components: object[], nets: object[], primitives: object[], anchors: object[], diagnostics: object[], airwires: object[], traceLengths: object[], groups: object[], anchorOffsets: object[] }}
12
15
  */
13
16
  static build(documentModel) {
14
- if (
15
- !PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
16
- ) {
17
+ const context = PcbInteractionPrimitiveModel.#context(documentModel)
18
+ if (context) return PcbPrimitivePreparation.prepareComplete(context)
19
+
20
+ const elements = CircuitJsonDocument.normalizeModel(
21
+ CircuitJsonPcbPrimitiveBuilder.elements(documentModel)
22
+ )
23
+ try {
24
+ return CircuitJsonPcbPrimitiveBuilder.build(elements)
25
+ } catch {
17
26
  return PcbInteractionPrimitiveModel.#emptyModel()
18
27
  }
19
-
20
- return CircuitJsonPcbPrimitiveBuilder.build(documentModel)
21
28
  }
22
29
 
23
30
  /**
@@ -26,13 +33,7 @@ export class PcbInteractionPrimitiveModel {
26
33
  * @returns {{ physicalLayers: object[], virtualLayers: object[] }}
27
34
  */
28
35
  static resolveLayerGroups(documentModel) {
29
- if (
30
- !PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
31
- ) {
32
- return { physicalLayers: [], virtualLayers: [] }
33
- }
34
-
35
- const model = CircuitJsonPcbPrimitiveBuilder.build(documentModel)
36
+ const model = PcbInteractionPrimitiveModel.build(documentModel)
36
37
  return {
37
38
  physicalLayers: model.layers,
38
39
  virtualLayers: model.virtualLayers || []
@@ -47,6 +48,21 @@ export class PcbInteractionPrimitiveModel {
47
48
  * @returns {{ snapped: boolean, point: { x: number, y: number } }}
48
49
  */
49
50
  static resolveSnapPoint(documentModel, point, options = {}) {
51
+ return PcbInteractionPrimitiveModel.resolveSnapPointFromModel(
52
+ PcbInteractionPrimitiveModel.build(documentModel),
53
+ point,
54
+ options
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Snaps against anchors from one already prepared primitive model.
60
+ * @param {{ anchors?: object[] }} model Prepared primitive model.
61
+ * @param {{ x?: unknown, y?: unknown }} point Board-space point.
62
+ * @param {{ tolerance?: number }} [options] Snap options.
63
+ * @returns {{ snapped: boolean, point: { x: number, y: number } }}
64
+ */
65
+ static resolveSnapPointFromModel(model, point, options = {}) {
50
66
  const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
51
67
  if (!normalizedPoint) return { snapped: false, point: { x: 0, y: 0 } }
52
68
 
@@ -57,8 +73,7 @@ export class PcbInteractionPrimitiveModel {
57
73
  let bestPoint = null
58
74
  let bestDistanceSq = Infinity
59
75
 
60
- for (const anchor of PcbInteractionPrimitiveModel.build(documentModel)
61
- .anchors) {
76
+ for (const anchor of model?.anchors || []) {
62
77
  const distanceSq = PcbInteractionPrimitiveModel.#distanceSquared(
63
78
  normalizedPoint,
64
79
  anchor.point
@@ -84,21 +99,66 @@ export class PcbInteractionPrimitiveModel {
84
99
  * @returns {object[]}
85
100
  */
86
101
  static hitTest(documentModel, point, options = {}) {
102
+ const model = PcbInteractionPrimitiveModel.build(documentModel)
103
+ return PcbInteractionPrimitiveModel.hitTestPrimitives(
104
+ model.primitives,
105
+ point,
106
+ options,
107
+ model.groups
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Applies the legacy exact narrow phase to prepared primitive candidates.
113
+ * @param {object[]} primitives Prepared primitive candidates.
114
+ * @param {{ x?: unknown, y?: unknown }} point Board-space point.
115
+ * @param {{ side?: 'top' | 'bottom', hiddenLayers?: string[], hiddenObjects?: string[], tolerance?: number }} [options] Hit-test options.
116
+ * @param {object[]} [groups] Prepared group rows.
117
+ * @returns {object[]} Prioritized hit candidates.
118
+ */
119
+ static hitTestPrimitives(primitives, point, options = {}, groups = []) {
120
+ return PcbInteractionPrimitiveModel.hitTestRecords(
121
+ (primitives || []).map((primitive, index) => ({
122
+ recordId: String(index),
123
+ primitive
124
+ })),
125
+ point,
126
+ options,
127
+ groups
128
+ ).map(({ recordId: _recordId, ...hit }) => hit)
129
+ }
130
+
131
+ /**
132
+ * Applies the exact narrow phase while preserving a caller-owned stable
133
+ * record identity independently from non-unique primitive ids.
134
+ * @param {{ recordId: string, primitive: object }[]} records Prepared primitive records.
135
+ * @param {{ x?: unknown, y?: unknown }} point Board-space point.
136
+ * @param {{ side?: 'top' | 'bottom', hiddenLayers?: string[], hiddenObjects?: string[], tolerance?: number }} [options] Hit-test options.
137
+ * @param {object[]} [groups] Prepared group rows.
138
+ * @returns {object[]} Prioritized hits carrying their stable record ids.
139
+ */
140
+ static hitTestRecords(records, point, options = {}, groups = []) {
87
141
  const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
88
142
  if (!normalizedPoint) return []
89
-
143
+ const preparedOptions =
144
+ PcbInteractionPrimitiveModel.#visibilityOptions(options)
90
145
  const tolerance = PcbInteractionPrimitiveModel.#number(
91
146
  options.tolerance,
92
147
  0.2
93
148
  )
94
- const model = PcbInteractionPrimitiveModel.build(documentModel)
95
149
  const groupsById = new Map(
96
- (model.groups || []).map((group) => [String(group.id || ''), group])
150
+ (groups || []).map((group) => [String(group.id || ''), group])
97
151
  )
98
152
  const hits = []
99
153
 
100
- for (const primitive of model.primitives) {
101
- if (!PcbInteractionPrimitiveModel.#isVisible(primitive, options)) {
154
+ for (const record of records || []) {
155
+ const primitive = record.primitive
156
+ if (
157
+ !PcbInteractionPrimitiveModel.#isVisible(
158
+ primitive,
159
+ preparedOptions
160
+ )
161
+ ) {
102
162
  continue
103
163
  }
104
164
  const distance = PcbInteractionPrimitiveModel.#hitDistance(
@@ -109,6 +169,7 @@ export class PcbInteractionPrimitiveModel {
109
169
  if (distance === null) continue
110
170
 
111
171
  hits.push({
172
+ recordId: String(record.recordId),
112
173
  ...PcbInteractionPrimitiveModel.#candidate(
113
174
  primitive,
114
175
  groupsById
@@ -126,14 +187,53 @@ export class PcbInteractionPrimitiveModel {
126
187
  }
127
188
 
128
189
  /**
129
- * Returns true when the document uses the element-array source format.
130
- * @param {object | object[]} documentModel Parsed document model.
131
- * @returns {boolean}
190
+ * Resolves conservative exact-geometry bounds for spatial indexing.
191
+ * @param {object} primitive Prepared PCB primitive.
192
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null} Interaction bounds.
132
193
  */
133
- static #isElementArrayDocument(documentModel) {
134
- return CircuitJsonDocument.isModel(
135
- CircuitJsonPcbPrimitiveBuilder.elements(documentModel)
136
- )
194
+ static interactionBounds(primitive) {
195
+ return PcbInteractionBounds.resolve(primitive)
196
+ }
197
+
198
+ /**
199
+ * Resolves one canonical context without exposing validation failures from
200
+ * this legacy tolerant facade.
201
+ * @param {unknown} documentModel Document result, model, or context.
202
+ * @returns {CircuitJsonDocumentContext | null} Prepared context.
203
+ */
204
+ static #context(documentModel) {
205
+ try {
206
+ if (documentModel instanceof CircuitJsonDocumentContext) {
207
+ return documentModel
208
+ }
209
+ if (
210
+ documentModel &&
211
+ typeof documentModel === 'object' &&
212
+ !Array.isArray(documentModel)
213
+ ) {
214
+ const schema = Object.getOwnPropertyDescriptor(
215
+ documentModel,
216
+ 'schema'
217
+ )
218
+ const model = Object.getOwnPropertyDescriptor(
219
+ documentModel,
220
+ 'model'
221
+ )
222
+ if (
223
+ schema &&
224
+ Object.hasOwn(schema, 'value') &&
225
+ schema.value === 'ecad-toolkit.document.v1' &&
226
+ model &&
227
+ Object.hasOwn(model, 'value') &&
228
+ Array.isArray(model.value)
229
+ ) {
230
+ return CircuitJsonDocumentContext.prepare(documentModel)
231
+ }
232
+ }
233
+ return null
234
+ } catch {
235
+ return null
236
+ }
137
237
  }
138
238
 
139
239
  /**
@@ -164,39 +264,59 @@ export class PcbInteractionPrimitiveModel {
164
264
  * @returns {boolean}
165
265
  */
166
266
  static #isVisible(primitive, options) {
167
- const hiddenLayers = new Set(
168
- (Array.isArray(options.hiddenLayers) ? options.hiddenLayers : [])
169
- .map(String)
170
- .filter(Boolean)
171
- )
172
- if (primitive.layer && hiddenLayers.has(String(primitive.layer))) {
267
+ if (
268
+ primitive.layer &&
269
+ options.hiddenLayers.has(String(primitive.layer))
270
+ ) {
173
271
  return false
174
272
  }
175
273
 
176
- const hiddenObjects = new Set(
177
- (Array.isArray(options.hiddenObjects) ? options.hiddenObjects : [])
178
- .map(String)
179
- .filter(Boolean)
180
- )
181
274
  if (
182
- hiddenObjects.has(
275
+ options.hiddenObjects.has(
183
276
  PcbInteractionPrimitiveModel.#componentSideObjectKey(primitive)
184
277
  )
185
278
  ) {
186
279
  return false
187
280
  }
188
281
  if (
189
- hiddenObjects.has(
282
+ options.hiddenObjects.has(
190
283
  PcbInteractionPrimitiveModel.#objectKey(primitive)
191
284
  )
192
285
  ) {
193
286
  return false
194
287
  }
195
288
 
196
- const side = String(options.side || '')
289
+ const side = options.side
197
290
  return !side || !primitive.side || primitive.side === side
198
291
  }
199
292
 
293
+ /**
294
+ * Builds visibility lookup sets once per hit-test call.
295
+ * @param {object} options Raw legacy hit options.
296
+ * @returns {{ side: string, hiddenLayers: Set<string>, hiddenObjects: Set<string> }} Prepared options.
297
+ */
298
+ static #visibilityOptions(options) {
299
+ return {
300
+ side: String(options.side || ''),
301
+ hiddenLayers: new Set(
302
+ (Array.isArray(options.hiddenLayers)
303
+ ? options.hiddenLayers
304
+ : []
305
+ )
306
+ .map(String)
307
+ .filter(Boolean)
308
+ ),
309
+ hiddenObjects: new Set(
310
+ (Array.isArray(options.hiddenObjects)
311
+ ? options.hiddenObjects
312
+ : []
313
+ )
314
+ .map(String)
315
+ .filter(Boolean)
316
+ )
317
+ }
318
+ }
319
+
200
320
  /**
201
321
  * Resolves component-side object keys for component-backed primitives.
202
322
  * @param {object} primitive Primitive row.
@@ -268,27 +388,40 @@ export class PcbInteractionPrimitiveModel {
268
388
  return distance <= width / 2 + tolerance ? distance : null
269
389
  }
270
390
  if (primitive.kind === 'via') {
271
- if (String(primitive.shape || 'circle') !== 'circle') {
272
- return PcbInteractionPrimitiveModel.#inside(
273
- point,
274
- primitive.bounds,
275
- tolerance
276
- )
277
- ? 0
278
- : null
279
- }
280
- const distance = Math.sqrt(
281
- PcbInteractionPrimitiveModel.#distanceSquared(point, primitive)
391
+ const hit = PcbInteractionPrimitiveModel.#shapeHit(
392
+ primitive,
393
+ point,
394
+ tolerance
282
395
  )
283
- return distance <= primitive.diameter / 2 + tolerance
284
- ? distance
285
- : null
396
+ if (hit === null) return null
397
+ return String(primitive.shape || 'circle') === 'circle'
398
+ ? Math.sqrt(
399
+ PcbInteractionPrimitiveModel.#distanceSquared(
400
+ point,
401
+ primitive
402
+ )
403
+ )
404
+ : hit
286
405
  }
287
- if (
288
- ['pad', 'zone', 'keepout', 'cutout', 'courtyard'].includes(
289
- primitive.kind
406
+ if (primitive.kind === 'pad') {
407
+ return PcbInteractionPrimitiveModel.#shapeHit(
408
+ primitive,
409
+ point,
410
+ tolerance
290
411
  )
412
+ }
413
+ if (
414
+ ['zone', 'keepout', 'cutout', 'courtyard'].includes(primitive.kind)
291
415
  ) {
416
+ const points =
417
+ PcbInteractionPrimitiveModel.#primitivePoints(primitive)
418
+ if (points.length >= 3) {
419
+ return PcbInteractionPrimitiveModel.#polygonHit(
420
+ points,
421
+ point,
422
+ tolerance
423
+ )
424
+ }
292
425
  return PcbInteractionPrimitiveModel.#inside(
293
426
  point,
294
427
  primitive.bounds,
@@ -298,15 +431,213 @@ export class PcbInteractionPrimitiveModel {
298
431
  : null
299
432
  }
300
433
  if (primitive.kind === 'board') {
301
- return PcbInteractionPrimitiveModel.#inside(
434
+ const points =
435
+ PcbInteractionPrimitiveModel.#primitivePoints(primitive)
436
+ const inside =
437
+ points.length >= 3
438
+ ? PcbInteractionPrimitiveModel.#polygonHit(
439
+ points,
440
+ point,
441
+ 0
442
+ ) !== null
443
+ : PcbInteractionPrimitiveModel.#inside(
444
+ point,
445
+ primitive.bounds,
446
+ 0
447
+ )
448
+ return inside ? Number.MAX_SAFE_INTEGER : null
449
+ }
450
+ return null
451
+ }
452
+
453
+ /**
454
+ * Applies exact common pad and via shape predicates.
455
+ * @param {object} primitive Shape primitive.
456
+ * @param {{ x: number, y: number }} point Board point.
457
+ * @param {number} tolerance Hit tolerance.
458
+ * @returns {number | null} Hit distance or null.
459
+ */
460
+ static #shapeHit(primitive, point, tolerance) {
461
+ const points = PcbInteractionPrimitiveModel.#primitivePoints(primitive)
462
+ if (points.length >= 3) {
463
+ return PcbInteractionPrimitiveModel.#polygonHit(
464
+ points,
302
465
  point,
303
- primitive.bounds,
466
+ tolerance
467
+ )
468
+ }
469
+ const center = PcbInteractionPrimitiveModel.#point(primitive)
470
+ if (!center) return null
471
+ const width = Math.max(
472
+ PcbInteractionPrimitiveModel.#number(
473
+ primitive.width ?? primitive.diameter,
304
474
  0
475
+ ),
476
+ 0
477
+ )
478
+ const height = Math.max(
479
+ PcbInteractionPrimitiveModel.#number(
480
+ primitive.height ?? primitive.diameter ?? primitive.width,
481
+ width
482
+ ),
483
+ 0
484
+ )
485
+ if (!width || !height) return null
486
+ const local = PcbInteractionPrimitiveModel.#rotatePoint(
487
+ point,
488
+ center,
489
+ -PcbInteractionPrimitiveModel.#number(primitive.rotation, 0)
490
+ )
491
+ const shape = String(primitive.shape || 'circle').toLowerCase()
492
+ if (['circle', 'oval_circle'].includes(shape)) {
493
+ const x = (local.x - center.x) / (width / 2 + tolerance)
494
+ const y = (local.y - center.y) / (height / 2 + tolerance)
495
+ return x * x + y * y <= 1 ? 0 : null
496
+ }
497
+ if (['pill', 'oval', 'rotated_pill', 'capsule'].includes(shape)) {
498
+ return PcbInteractionPrimitiveModel.#capsuleHit(
499
+ local,
500
+ center,
501
+ width,
502
+ height,
503
+ tolerance
305
504
  )
306
- ? Number.MAX_SAFE_INTEGER
307
- : null
308
505
  }
309
- return null
506
+ if (['rounded_rect', 'roundrect'].includes(shape)) {
507
+ return PcbInteractionPrimitiveModel.#roundedRectHit(
508
+ local,
509
+ center,
510
+ width,
511
+ height,
512
+ PcbInteractionPrimitiveModel.#number(primitive.radius, 0),
513
+ tolerance
514
+ )
515
+ }
516
+ return Math.abs(local.x - center.x) <= width / 2 + tolerance &&
517
+ Math.abs(local.y - center.y) <= height / 2 + tolerance
518
+ ? 0
519
+ : null
520
+ }
521
+
522
+ /**
523
+ * Tests a point against a horizontal or vertical capsule.
524
+ * @param {{ x: number, y: number }} point Local point.
525
+ * @param {{ x: number, y: number }} center Shape center.
526
+ * @param {number} width Shape width.
527
+ * @param {number} height Shape height.
528
+ * @param {number} tolerance Hit tolerance.
529
+ * @returns {number | null} Hit distance or null.
530
+ */
531
+ static #capsuleHit(point, center, width, height, tolerance) {
532
+ const radius = Math.min(width, height) / 2
533
+ const horizontal = width >= height
534
+ const halfSegment = Math.abs(width - height) / 2
535
+ const start = horizontal
536
+ ? { x: center.x - halfSegment, y: center.y }
537
+ : { x: center.x, y: center.y - halfSegment }
538
+ const end = horizontal
539
+ ? { x: center.x + halfSegment, y: center.y }
540
+ : { x: center.x, y: center.y + halfSegment }
541
+ return PcbInteractionPrimitiveModel.#distanceToSegment(
542
+ point,
543
+ start,
544
+ end
545
+ ) <=
546
+ radius + tolerance
547
+ ? 0
548
+ : null
549
+ }
550
+
551
+ /**
552
+ * Tests one rounded rectangle in local coordinates.
553
+ * @param {{ x: number, y: number }} point Local point.
554
+ * @param {{ x: number, y: number }} center Shape center.
555
+ * @param {number} width Shape width.
556
+ * @param {number} height Shape height.
557
+ * @param {number} radius Requested corner radius.
558
+ * @param {number} tolerance Hit tolerance.
559
+ * @returns {number | null} Hit distance or null.
560
+ */
561
+ static #roundedRectHit(point, center, width, height, radius, tolerance) {
562
+ const corner = Math.max(0, Math.min(radius, width / 2, height / 2))
563
+ const x = Math.max(
564
+ Math.abs(point.x - center.x) - (width / 2 - corner),
565
+ 0
566
+ )
567
+ const y = Math.max(
568
+ Math.abs(point.y - center.y) - (height / 2 - corner),
569
+ 0
570
+ )
571
+ return Math.hypot(x, y) <= corner + tolerance ? 0 : null
572
+ }
573
+
574
+ /**
575
+ * Tests a point against a polygon with edge tolerance.
576
+ * @param {{ x: number, y: number }[]} points Polygon points.
577
+ * @param {{ x: number, y: number }} point Board point.
578
+ * @param {number} tolerance Hit tolerance.
579
+ * @returns {number | null} Hit distance or null.
580
+ */
581
+ static #polygonHit(points, point, tolerance) {
582
+ let inside = false
583
+ let distance = Infinity
584
+ for (
585
+ let index = 0, previous = points.length - 1;
586
+ index < points.length;
587
+ previous = index, index += 1
588
+ ) {
589
+ const start = points[previous]
590
+ const end = points[index]
591
+ distance = Math.min(
592
+ distance,
593
+ PcbInteractionPrimitiveModel.#distanceToSegment(
594
+ point,
595
+ start,
596
+ end
597
+ )
598
+ )
599
+ if (
600
+ start.y > point.y !== end.y > point.y &&
601
+ point.x <
602
+ ((end.x - start.x) * (point.y - start.y)) /
603
+ (end.y - start.y) +
604
+ start.x
605
+ ) {
606
+ inside = !inside
607
+ }
608
+ }
609
+ return inside || distance <= tolerance ? 0 : null
610
+ }
611
+
612
+ /**
613
+ * Resolves valid polygon points from one primitive.
614
+ * @param {object} primitive PCB primitive.
615
+ * @returns {{ x: number, y: number }[]} Points.
616
+ */
617
+ static #primitivePoints(primitive) {
618
+ return (Array.isArray(primitive?.points) ? primitive.points : [])
619
+ .map(PcbInteractionPrimitiveModel.#point)
620
+ .filter(Boolean)
621
+ }
622
+
623
+ /**
624
+ * Rotates a point around one center.
625
+ * @param {{ x: number, y: number }} point Point.
626
+ * @param {{ x: number, y: number }} center Center.
627
+ * @param {number} degrees Counter-clockwise degrees.
628
+ * @returns {{ x: number, y: number }} Rotated point.
629
+ */
630
+ static #rotatePoint(point, center, degrees) {
631
+ if (!degrees) return point
632
+ const radians = (degrees * Math.PI) / 180
633
+ const cos = Math.cos(radians)
634
+ const sin = Math.sin(radians)
635
+ const x = point.x - center.x
636
+ const y = point.y - center.y
637
+ return {
638
+ x: center.x + x * cos - y * sin,
639
+ y: center.y + x * sin + y * cos
640
+ }
310
641
  }
311
642
 
312
643
  /**
@@ -0,0 +1,70 @@
1
+ import { BinaryDataSnapshot } from './context/BinaryDataSnapshot.mjs'
2
+ import { ToolkitAsset } from './contracts/ToolkitAsset.mjs'
3
+
4
+ /**
5
+ * Owns direct asynchronous project payloads before host callbacks run.
6
+ */
7
+ export class ProjectAsyncInputOwner {
8
+ /**
9
+ * Owns candidate bytes and prepares companion assets once.
10
+ * @param {{ entries: object[] }} classified Classified entries.
11
+ * @param {'none' | 'metadata' | 'full'} assetMode Asset decode mode.
12
+ * @returns {void}
13
+ */
14
+ static own(classified, assetMode) {
15
+ const snapshots = new WeakMap()
16
+ for (const entry of classified.entries) {
17
+ if (entry.name.toLowerCase().endsWith('.json')) {
18
+ entry.input.data = ProjectAsyncInputOwner.#binary(
19
+ entry.input.data,
20
+ snapshots
21
+ )
22
+ continue
23
+ }
24
+ if (assetMode === 'none') continue
25
+ entry.companionAsset = ProjectAsyncInputOwner.#companionAsset(
26
+ entry,
27
+ assetMode
28
+ )
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Copies one binary value once while leaving immutable text unchanged.
34
+ * @param {unknown} value Entry data.
35
+ * @param {WeakMap<object, unknown>} snapshots Request-local binary copies.
36
+ * @returns {unknown} Owned data.
37
+ */
38
+ static #binary(value, snapshots) {
39
+ if (typeof value === 'string') return value
40
+ if (value && typeof value === 'object' && snapshots.has(value)) {
41
+ return snapshots.get(value)
42
+ }
43
+ const owned = BinaryDataSnapshot.clone(value)
44
+ if (value && typeof value === 'object') snapshots.set(value, owned)
45
+ return owned
46
+ }
47
+
48
+ /**
49
+ * Prepares one companion asset as the final canonical payload snapshot.
50
+ * @param {object} entry Classified companion entry.
51
+ * @param {'metadata' | 'full'} mode Asset decode mode.
52
+ * @returns {Record<string, any>} Prepared companion asset.
53
+ */
54
+ static #companionAsset(entry, mode) {
55
+ return ToolkitAsset.prepare(
56
+ {
57
+ kind: 'companion',
58
+ name: entry.name,
59
+ mediaType: 'application/octet-stream',
60
+ byteLength: entry.byteLength,
61
+ data: entry.input.data,
62
+ source: { entryName: entry.name }
63
+ },
64
+ { mode }
65
+ )
66
+ }
67
+ }
68
+
69
+ Object.freeze(ProjectAsyncInputOwner.prototype)
70
+ Object.freeze(ProjectAsyncInputOwner)