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,701 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ const NODE_SIZE = 16
4
+ const MAX_RECORDS = 100_000
5
+ const MAX_RECORD_ID_LENGTH = 1024
6
+ const MAX_NESTED_ARRAY_LENGTH = 100_000
7
+ const MAX_SNAPSHOT_DEPTH = 64
8
+ const MAX_SNAPSHOT_SLOTS = 1_000_000
9
+ const MAX_TOLERANCE = 1_000_000
10
+
11
+ /**
12
+ * Builds a deterministic packed spatial index over millimeter bounds.
13
+ */
14
+ export class PcbSpatialIndex {
15
+ #nodeCount
16
+ #recordCount
17
+ #root
18
+
19
+ /**
20
+ * Creates an immutable packed index from source-toolkit records.
21
+ * @param {unknown} records Records with stable ids and bounds.
22
+ * @returns {PcbSpatialIndex} Spatial index.
23
+ */
24
+ static create(records) {
25
+ const entries = PcbSpatialIndex.#entries(
26
+ PcbSpatialIndex.#recordList(records)
27
+ )
28
+ const packed = PcbSpatialIndex.#pack(entries)
29
+ return new PcbSpatialIndex(packed.root, entries.length, packed.nodes)
30
+ }
31
+
32
+ /**
33
+ * Creates one prepared spatial index instance.
34
+ * @param {object | null} root Packed root node.
35
+ * @param {number} recordCount Record count.
36
+ * @param {number} nodeCount Node count.
37
+ */
38
+ constructor(root, recordCount, nodeCount) {
39
+ this.#root = root
40
+ this.#recordCount = recordCount
41
+ this.#nodeCount = nodeCount
42
+ Object.freeze(this)
43
+ }
44
+
45
+ /**
46
+ * Returns records whose bounds overlap a point tolerance square.
47
+ * @param {unknown} point Millimeter point.
48
+ * @param {unknown} [tolerance] Nonnegative tolerance.
49
+ * @returns {object[]} Candidate source records in stable source order.
50
+ */
51
+ candidates(point, tolerance = 0) {
52
+ const normalizedPoint = PcbSpatialIndex.#point(point)
53
+ if (
54
+ typeof tolerance !== 'number' ||
55
+ !Number.isFinite(tolerance) ||
56
+ tolerance < 0 ||
57
+ tolerance > MAX_TOLERANCE
58
+ ) {
59
+ throw PcbSpatialIndex.#queryError(
60
+ 'Spatial query tolerance must be a nonnegative finite number.'
61
+ )
62
+ }
63
+ return this.search({
64
+ minX: normalizedPoint.x - tolerance,
65
+ minY: normalizedPoint.y - tolerance,
66
+ maxX: normalizedPoint.x + tolerance,
67
+ maxY: normalizedPoint.y + tolerance
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Returns records whose bounds intersect one search rectangle.
73
+ * @param {unknown} bounds Millimeter search bounds.
74
+ * @returns {object[]} Candidate source records in stable source order.
75
+ */
76
+ search(bounds) {
77
+ const query = PcbSpatialIndex.#bounds(bounds, 'query')
78
+ if (!this.#root) return []
79
+ const matches = []
80
+ const stack = [this.#root]
81
+ while (stack.length) {
82
+ const node = stack.pop()
83
+ if (!PcbSpatialIndex.#intersects(node.bounds, query)) continue
84
+ if (node.entries) {
85
+ for (const entry of node.entries) {
86
+ if (PcbSpatialIndex.#intersects(entry.bounds, query)) {
87
+ matches.push(entry)
88
+ }
89
+ }
90
+ continue
91
+ }
92
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
93
+ stack.push(node.children[index])
94
+ }
95
+ }
96
+ return matches
97
+ .sort((left, right) => left.order - right.order)
98
+ .map((entry) => entry.record)
99
+ }
100
+
101
+ /**
102
+ * Returns clone-safe index construction statistics.
103
+ * @returns {{ records: number, nodes: number }} Index statistics.
104
+ */
105
+ get statistics() {
106
+ return { records: this.#recordCount, nodes: this.#nodeCount }
107
+ }
108
+
109
+ /**
110
+ * Normalizes source records and rejects duplicate ids.
111
+ * @param {object[]} records Record candidates.
112
+ * @returns {object[]} Packed entries.
113
+ */
114
+ static #entries(records) {
115
+ const ids = new Set()
116
+ const snapshotState = {
117
+ seen: new WeakMap(),
118
+ slots: 0
119
+ }
120
+ return records.map((record, order) => {
121
+ const snapshot = PcbSpatialIndex.#snapshotRecord(
122
+ record,
123
+ snapshotState
124
+ )
125
+ const fields = PcbSpatialIndex.#recordFields(snapshot)
126
+ const id = fields.id
127
+ if (
128
+ typeof id !== 'string' ||
129
+ !id ||
130
+ id !== id.trim() ||
131
+ id.length > MAX_RECORD_ID_LENGTH ||
132
+ ids.has(id)
133
+ ) {
134
+ throw PcbSpatialIndex.#recordError(
135
+ 'Spatial index record ids must be bounded, trimmed, non-empty, and unique.'
136
+ )
137
+ }
138
+ ids.add(id)
139
+ return {
140
+ id,
141
+ order,
142
+ record: snapshot,
143
+ bounds: PcbSpatialIndex.#bounds(fields.bounds, 'record')
144
+ }
145
+ })
146
+ }
147
+
148
+ /**
149
+ * Reads one exact dense plain array without invoking indexed accessors.
150
+ * @param {unknown} records Record-list candidate.
151
+ * @returns {object[]} Safely read record values.
152
+ */
153
+ static #recordList(records) {
154
+ if (!Array.isArray(records)) {
155
+ throw new TypeError(
156
+ 'Spatial index records must be a dense plain array.'
157
+ )
158
+ }
159
+ let prototype
160
+ let descriptors
161
+ try {
162
+ prototype = Object.getPrototypeOf(records)
163
+ descriptors = Object.getOwnPropertyDescriptors(records)
164
+ } catch {
165
+ throw new TypeError(
166
+ 'Spatial index records could not be inspected safely.'
167
+ )
168
+ }
169
+ if (prototype !== Array.prototype) {
170
+ throw new TypeError(
171
+ 'Spatial index records must be a dense plain array.'
172
+ )
173
+ }
174
+ const length = PcbSpatialIndex.#dataValue(descriptors.length)
175
+ if (!Number.isSafeInteger(length) || length < 0) {
176
+ throw new TypeError(
177
+ 'Spatial index records must have a safe array length.'
178
+ )
179
+ }
180
+ if (length > MAX_RECORDS) {
181
+ throw PcbSpatialIndex.#recordError(
182
+ `Spatial indexes support at most ${MAX_RECORDS} records.`
183
+ )
184
+ }
185
+ const values = []
186
+ const allowedKeys = new Set(['length'])
187
+ for (let index = 0; index < length; index += 1) {
188
+ const key = String(index)
189
+ const descriptor = descriptors[key]
190
+ if (!descriptor || descriptor.get || descriptor.set) {
191
+ throw new TypeError(
192
+ 'Spatial index records must be a dense data-only array.'
193
+ )
194
+ }
195
+ allowedKeys.add(key)
196
+ values.push(descriptor.value)
197
+ }
198
+ if (
199
+ Reflect.ownKeys(descriptors).some(
200
+ (key) => typeof key !== 'string' || !allowedKeys.has(key)
201
+ )
202
+ ) {
203
+ throw new TypeError(
204
+ 'Spatial index records must not contain custom properties.'
205
+ )
206
+ }
207
+ return values
208
+ }
209
+
210
+ /**
211
+ * Reads required own record fields without invoking accessors.
212
+ * @param {unknown} record Record candidate.
213
+ * @returns {{ id: unknown, bounds: unknown }} Record fields.
214
+ */
215
+ static #recordFields(record) {
216
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
217
+ throw PcbSpatialIndex.#recordError(
218
+ 'Spatial index records must be plain objects.'
219
+ )
220
+ }
221
+ let prototype
222
+ let descriptors
223
+ try {
224
+ prototype = Object.getPrototypeOf(record)
225
+ descriptors = Object.getOwnPropertyDescriptors(record)
226
+ } catch {
227
+ throw PcbSpatialIndex.#recordError(
228
+ 'Spatial index record could not be inspected safely.'
229
+ )
230
+ }
231
+ if (prototype !== Object.prototype && prototype !== null) {
232
+ throw PcbSpatialIndex.#recordError(
233
+ 'Spatial index records must be plain objects.'
234
+ )
235
+ }
236
+ const id = PcbSpatialIndex.#dataValue(descriptors.id)
237
+ const bounds = PcbSpatialIndex.#dataValue(descriptors.bounds)
238
+ if (id === undefined || bounds === undefined) {
239
+ throw PcbSpatialIndex.#recordError(
240
+ 'Spatial index records require own id and bounds fields.'
241
+ )
242
+ }
243
+ return { id, bounds }
244
+ }
245
+
246
+ /**
247
+ * Clones and freezes one data-only record without invoking accessors.
248
+ * @param {object} record Source record.
249
+ * @param {{ seen: WeakMap<object, object>, slots: number }} state Construction-wide snapshot state.
250
+ * @returns {object} Immutable clone-safe snapshot.
251
+ */
252
+ static #snapshotRecord(record, state) {
253
+ return PcbSpatialIndex.#snapshotValue(record, state, 0)
254
+ }
255
+
256
+ /**
257
+ * Recursively snapshots bounded plain data with cycle preservation.
258
+ * @param {unknown} value Source value.
259
+ * @param {{ seen: WeakMap<object, object>, slots: number }} state Construction-wide traversal state.
260
+ * @param {number} depth Current depth.
261
+ * @returns {any} Frozen clone-safe value.
262
+ */
263
+ static #snapshotValue(value, state, depth) {
264
+ if (
265
+ value === null ||
266
+ ['string', 'boolean', 'undefined'].includes(typeof value)
267
+ ) {
268
+ return value
269
+ }
270
+ if (typeof value === 'number') {
271
+ if (!Number.isFinite(value)) {
272
+ throw PcbSpatialIndex.#recordError(
273
+ 'Spatial index record data must contain finite numbers.'
274
+ )
275
+ }
276
+ return value
277
+ }
278
+ if (typeof value !== 'object') {
279
+ throw PcbSpatialIndex.#recordError(
280
+ 'Spatial index record data must be clone-safe plain data.'
281
+ )
282
+ }
283
+ if (state.seen.has(value)) return state.seen.get(value)
284
+ if (depth > MAX_SNAPSHOT_DEPTH) {
285
+ throw PcbSpatialIndex.#recordError(
286
+ 'Spatial index record data exceeds snapshot limits.'
287
+ )
288
+ }
289
+
290
+ let prototype
291
+ let descriptors
292
+ try {
293
+ prototype = Object.getPrototypeOf(value)
294
+ descriptors = Object.getOwnPropertyDescriptors(value)
295
+ } catch {
296
+ throw PcbSpatialIndex.#recordError(
297
+ 'Spatial index record data could not be inspected safely.'
298
+ )
299
+ }
300
+ const isArray = Array.isArray(value)
301
+ if (
302
+ (isArray && prototype !== Array.prototype) ||
303
+ (!isArray && prototype !== Object.prototype && prototype !== null)
304
+ ) {
305
+ throw PcbSpatialIndex.#recordError(
306
+ 'Spatial index record data must use plain containers.'
307
+ )
308
+ }
309
+ const keys = PcbSpatialIndex.#snapshotKeys(
310
+ descriptors,
311
+ isArray,
312
+ PcbSpatialIndex.#dataValue(descriptors.length),
313
+ state
314
+ )
315
+ const target = isArray
316
+ ? []
317
+ : Object.create(prototype === null ? null : Object.prototype)
318
+ state.seen.set(value, target)
319
+ for (const key of keys) {
320
+ if (isArray && key === 'length') continue
321
+ const descriptor = descriptors[key]
322
+ Object.defineProperty(target, key, {
323
+ value: PcbSpatialIndex.#snapshotValue(
324
+ descriptor.value,
325
+ state,
326
+ depth + 1
327
+ ),
328
+ enumerable: true,
329
+ configurable: true,
330
+ writable: true
331
+ })
332
+ }
333
+ return Object.freeze(target)
334
+ }
335
+
336
+ /**
337
+ * Validates and returns enumerable data keys for one snapshot container.
338
+ * @param {Record<PropertyKey, PropertyDescriptor>} descriptors Own descriptors.
339
+ * @param {boolean} isArray Whether the container is an array.
340
+ * @param {number} length Array length when applicable.
341
+ * @param {{ seen: WeakMap<object, object>, slots: number }} state Construction-wide traversal state.
342
+ * @returns {string[]} Safe source keys.
343
+ */
344
+ static #snapshotKeys(descriptors, isArray, length, state) {
345
+ const keys = Reflect.ownKeys(descriptors)
346
+ if (!isArray) {
347
+ if (
348
+ keys.some(
349
+ (key) =>
350
+ typeof key !== 'string' ||
351
+ descriptors[key].enumerable !== true ||
352
+ !PcbSpatialIndex.#isDataDescriptor(descriptors[key])
353
+ )
354
+ ) {
355
+ throw PcbSpatialIndex.#recordError(
356
+ 'Spatial index record data must use enumerable string fields.'
357
+ )
358
+ }
359
+ PcbSpatialIndex.#reserveSnapshotSlots(state, 1 + keys.length)
360
+ return keys
361
+ }
362
+ if (
363
+ !Number.isSafeInteger(length) ||
364
+ length < 0 ||
365
+ length > MAX_NESTED_ARRAY_LENGTH
366
+ ) {
367
+ throw PcbSpatialIndex.#recordError(
368
+ `Spatial index record arrays support at most ${MAX_NESTED_ARRAY_LENGTH} values.`
369
+ )
370
+ }
371
+ if (
372
+ keys.length !== length + 1 ||
373
+ keys.some(
374
+ (key) =>
375
+ key !== 'length' &&
376
+ (!PcbSpatialIndex.#isArrayIndex(key, length) ||
377
+ descriptors[key].enumerable !== true ||
378
+ !PcbSpatialIndex.#isDataDescriptor(descriptors[key]))
379
+ )
380
+ ) {
381
+ throw PcbSpatialIndex.#recordError(
382
+ 'Spatial index record arrays must be dense and data-only.'
383
+ )
384
+ }
385
+ PcbSpatialIndex.#reserveSnapshotSlots(state, 1 + length)
386
+ return keys
387
+ }
388
+
389
+ /**
390
+ * Returns whether a descriptor is an own data property.
391
+ * @param {PropertyDescriptor | undefined} descriptor Descriptor candidate.
392
+ * @returns {boolean} Whether the descriptor contains a data value.
393
+ */
394
+ static #isDataDescriptor(descriptor) {
395
+ return Boolean(
396
+ descriptor &&
397
+ Object.hasOwn(descriptor, 'value') &&
398
+ !descriptor.get &&
399
+ !descriptor.set
400
+ )
401
+ }
402
+
403
+ /**
404
+ * Returns whether a key is a canonical dense array index below length.
405
+ * @param {PropertyKey} key Property key.
406
+ * @param {number} length Validated array length.
407
+ * @returns {boolean} Whether the key is a valid array index.
408
+ */
409
+ static #isArrayIndex(key, length) {
410
+ if (typeof key !== 'string' || !key) return false
411
+ const index = Number(key)
412
+ return (
413
+ Number.isSafeInteger(index) &&
414
+ index >= 0 &&
415
+ index < length &&
416
+ String(index) === key
417
+ )
418
+ }
419
+
420
+ /**
421
+ * Reserves bounded aggregate slots before allocating snapshot containers.
422
+ * @param {{ slots: number }} state Construction-wide traversal state.
423
+ * @param {number} count Container and property slots to reserve.
424
+ * @returns {void}
425
+ */
426
+ static #reserveSnapshotSlots(state, count) {
427
+ if (
428
+ !Number.isSafeInteger(count) ||
429
+ count < 0 ||
430
+ state.slots > MAX_SNAPSHOT_SLOTS - count
431
+ ) {
432
+ throw PcbSpatialIndex.#recordError(
433
+ `Spatial index record data supports at most ${MAX_SNAPSHOT_SLOTS} aggregate slots.`
434
+ )
435
+ }
436
+ state.slots += count
437
+ }
438
+
439
+ /**
440
+ * Returns a safe own data value or undefined.
441
+ * @param {PropertyDescriptor | undefined} descriptor Field descriptor.
442
+ * @returns {unknown} Data value.
443
+ */
444
+ static #dataValue(descriptor) {
445
+ return descriptor && !descriptor.get && !descriptor.set
446
+ ? descriptor.value
447
+ : undefined
448
+ }
449
+
450
+ /**
451
+ * Normalizes a point through own data descriptors.
452
+ * @param {unknown} point Point candidate.
453
+ * @returns {{ x: number, y: number }} Normalized point.
454
+ */
455
+ static #point(point) {
456
+ const fields = PcbSpatialIndex.#plainFields(point, ['x', 'y'], 'query')
457
+ if (![fields.x, fields.y].every(PcbSpatialIndex.#finite)) {
458
+ throw PcbSpatialIndex.#queryError(
459
+ 'Spatial query point requires finite x and y numbers.'
460
+ )
461
+ }
462
+ return { x: fields.x, y: fields.y }
463
+ }
464
+
465
+ /**
466
+ * Normalizes rectangle bounds through own data descriptors.
467
+ * @param {unknown} bounds Bounds candidate.
468
+ * @param {'query' | 'record'} kind Error category.
469
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
470
+ */
471
+ static #bounds(bounds, kind) {
472
+ const names = ['minX', 'minY', 'maxX', 'maxY']
473
+ const fields = PcbSpatialIndex.#plainFields(bounds, names, kind)
474
+ if (
475
+ !names.every((name) => PcbSpatialIndex.#finite(fields[name])) ||
476
+ fields.minX > fields.maxX ||
477
+ fields.minY > fields.maxY
478
+ ) {
479
+ const error =
480
+ kind === 'record'
481
+ ? PcbSpatialIndex.#recordError
482
+ : PcbSpatialIndex.#queryError
483
+ throw error('Spatial bounds must contain ordered finite numbers.')
484
+ }
485
+ return Object.fromEntries(names.map((name) => [name, fields[name]]))
486
+ }
487
+
488
+ /**
489
+ * Reads named own fields from a plain object without accessors.
490
+ * @param {unknown} value Object candidate.
491
+ * @param {string[]} names Required names.
492
+ * @param {'query' | 'record'} kind Error category.
493
+ * @returns {Record<string, any>} Safe field values.
494
+ */
495
+ static #plainFields(value, names, kind) {
496
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
497
+ throw new TypeError('Spatial values must be plain objects.')
498
+ }
499
+ let prototype
500
+ let descriptors
501
+ try {
502
+ prototype = Object.getPrototypeOf(value)
503
+ descriptors = Object.getOwnPropertyDescriptors(value)
504
+ } catch {
505
+ throw new TypeError('Spatial value could not be inspected safely.')
506
+ }
507
+ if (prototype !== Object.prototype && prototype !== null) {
508
+ throw new TypeError('Spatial values must be plain objects.')
509
+ }
510
+ const result = {}
511
+ for (const name of names) {
512
+ const entry = PcbSpatialIndex.#dataValue(descriptors[name])
513
+ if (entry === undefined) {
514
+ throw new TypeError(
515
+ 'Spatial value is missing a required data field.'
516
+ )
517
+ }
518
+ result[name] = entry
519
+ }
520
+ return result
521
+ }
522
+
523
+ /**
524
+ * Packs entries into Morton-ordered fixed-fanout nodes.
525
+ * @param {object[]} entries Normalized entries.
526
+ * @returns {{ root: object | null, nodes: number }} Packed tree.
527
+ */
528
+ static #pack(entries) {
529
+ if (!entries.length) return { root: null, nodes: 0 }
530
+ const bounds = PcbSpatialIndex.#mergeBounds(entries)
531
+ let level = [...entries]
532
+ .map((entry) => ({
533
+ ...entry,
534
+ morton: PcbSpatialIndex.#morton(entry.bounds, bounds)
535
+ }))
536
+ .sort(
537
+ (left, right) =>
538
+ left.morton - right.morton ||
539
+ PcbSpatialIndex.#compare(left.id, right.id) ||
540
+ left.order - right.order
541
+ )
542
+ let nodes = 0
543
+ level = PcbSpatialIndex.#chunks(level, NODE_SIZE).map((chunk) => {
544
+ nodes += 1
545
+ return {
546
+ bounds: PcbSpatialIndex.#mergeBounds(chunk),
547
+ entries: chunk
548
+ }
549
+ })
550
+ while (level.length > 1) {
551
+ level = PcbSpatialIndex.#chunks(level, NODE_SIZE).map((chunk) => {
552
+ nodes += 1
553
+ return {
554
+ bounds: PcbSpatialIndex.#mergeBounds(chunk),
555
+ children: chunk
556
+ }
557
+ })
558
+ }
559
+ return { root: level[0], nodes }
560
+ }
561
+
562
+ /**
563
+ * Computes a stable 32-bit Morton key for one bounds center.
564
+ * @param {object} bounds Entry bounds.
565
+ * @param {object} extent Global extent.
566
+ * @returns {number} Unsigned Morton key.
567
+ */
568
+ static #morton(bounds, extent) {
569
+ const width = extent.maxX - extent.minX || 1
570
+ const height = extent.maxY - extent.minY || 1
571
+ const x = Math.round(
572
+ Math.max(
573
+ 0,
574
+ Math.min(
575
+ 65535,
576
+ (((bounds.minX + bounds.maxX) / 2 - extent.minX) / width) *
577
+ 65535
578
+ )
579
+ )
580
+ )
581
+ const y = Math.round(
582
+ Math.max(
583
+ 0,
584
+ Math.min(
585
+ 65535,
586
+ (((bounds.minY + bounds.maxY) / 2 - extent.minY) / height) *
587
+ 65535
588
+ )
589
+ )
590
+ )
591
+ return (
592
+ (PcbSpatialIndex.#spreadBits(x) |
593
+ (PcbSpatialIndex.#spreadBits(y) << 1)) >>>
594
+ 0
595
+ )
596
+ }
597
+
598
+ /**
599
+ * Spreads 16 input bits across even positions of a 32-bit word.
600
+ * @param {number} value Unsigned 16-bit value.
601
+ * @returns {number} Spread unsigned word.
602
+ */
603
+ static #spreadBits(value) {
604
+ let result = value & 0xffff
605
+ result = (result | (result << 8)) & 0x00ff00ff
606
+ result = (result | (result << 4)) & 0x0f0f0f0f
607
+ result = (result | (result << 2)) & 0x33333333
608
+ return (result | (result << 1)) & 0x55555555
609
+ }
610
+
611
+ /**
612
+ * Merges entry or node bounds.
613
+ * @param {object[]} values Entries or nodes.
614
+ * @returns {object} Merged bounds.
615
+ */
616
+ static #mergeBounds(values) {
617
+ return values.reduce(
618
+ (result, value) => ({
619
+ minX: Math.min(result.minX, value.bounds.minX),
620
+ minY: Math.min(result.minY, value.bounds.minY),
621
+ maxX: Math.max(result.maxX, value.bounds.maxX),
622
+ maxY: Math.max(result.maxY, value.bounds.maxY)
623
+ }),
624
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
625
+ )
626
+ }
627
+
628
+ /**
629
+ * Splits a list into fixed-size chunks.
630
+ * @param {object[]} values Values.
631
+ * @param {number} size Chunk size.
632
+ * @returns {object[][]} Chunks.
633
+ */
634
+ static #chunks(values, size) {
635
+ const result = []
636
+ for (let index = 0; index < values.length; index += size) {
637
+ result.push(values.slice(index, index + size))
638
+ }
639
+ return result
640
+ }
641
+
642
+ /**
643
+ * Returns whether two inclusive bounds overlap.
644
+ * @param {object} left First bounds.
645
+ * @param {object} right Second bounds.
646
+ * @returns {boolean} Whether bounds intersect.
647
+ */
648
+ static #intersects(left, right) {
649
+ return !(
650
+ left.maxX < right.minX ||
651
+ left.minX > right.maxX ||
652
+ left.maxY < right.minY ||
653
+ left.minY > right.maxY
654
+ )
655
+ }
656
+
657
+ /**
658
+ * Returns true only for finite primitive numbers.
659
+ * @param {unknown} value Numeric candidate.
660
+ * @returns {boolean} Whether value is finite.
661
+ */
662
+ static #finite(value) {
663
+ return typeof value === 'number' && Number.isFinite(value)
664
+ }
665
+
666
+ /**
667
+ * Compares stable ids by code point.
668
+ * @param {string} left Left id.
669
+ * @param {string} right Right id.
670
+ * @returns {number} Ordering value.
671
+ */
672
+ static #compare(left, right) {
673
+ return left < right ? -1 : left > right ? 1 : 0
674
+ }
675
+
676
+ /**
677
+ * Creates a typed spatial-record error.
678
+ * @param {string} message Failure message.
679
+ * @returns {ToolkitError} Typed error.
680
+ */
681
+ static #recordError(message) {
682
+ return new ToolkitError(message, {
683
+ code: 'ERR_SPATIAL_INDEX_RECORD',
684
+ category: 'validation',
685
+ format: 'circuitjson'
686
+ })
687
+ }
688
+
689
+ /**
690
+ * Creates a typed spatial-query error.
691
+ * @param {string} message Failure message.
692
+ * @returns {ToolkitError} Typed error.
693
+ */
694
+ static #queryError(message) {
695
+ return new ToolkitError(message, {
696
+ code: 'ERR_SPATIAL_INDEX_QUERY',
697
+ category: 'validation',
698
+ format: 'circuitjson'
699
+ })
700
+ }
701
+ }