circuitjson-toolkit 1.0.17 → 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.
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- 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
|
+
}
|