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,596 @@
1
+ import { CircuitJsonIndexer } from '../CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from '../CircuitJsonUnits.mjs'
3
+
4
+ const POINT_LOCATION_LEAF_SIZE = 12
5
+ const POINT_LOCATION_MAX_DEPTH = 12
6
+ const POINT_LOCATION_MAX_NODES = 4096
7
+
8
+ /**
9
+ * Keeps aggregate board geometry and per-outline surface heights together.
10
+ */
11
+ export class Scene3dBoardModel {
12
+ #board
13
+ #outlines
14
+ #pointCache
15
+ #pointIndex
16
+
17
+ /**
18
+ * Creates one board model from all CircuitJSON board rows.
19
+ * @param {object[]} boards Board elements.
20
+ * @param {object[]} cutouts Board cutout elements.
21
+ * @param {number} fallbackThickness Default millimeter thickness.
22
+ * @param {boolean} overrideThickness Whether the option overrides every board.
23
+ */
24
+ constructor(boards, cutouts, fallbackThickness, overrideThickness) {
25
+ this.#outlines = boards.map((board) =>
26
+ Scene3dBoardModel.#outline(
27
+ board,
28
+ fallbackThickness,
29
+ overrideThickness
30
+ )
31
+ )
32
+ const entries = Scene3dBoardModel.#pointEntries(this.#outlines)
33
+ this.#pointCache = new Map()
34
+ this.#pointIndex = Scene3dBoardModel.#pointNode(
35
+ entries,
36
+ entries.length ? Scene3dBoardModel.#entryBounds(entries) : null,
37
+ 0,
38
+ { nodes: 0 }
39
+ )
40
+ const bounds = Scene3dBoardModel.#bounds(this.#outlines)
41
+ this.#board = {
42
+ id: this.#outlines[0]?.id || '',
43
+ center: {
44
+ x: (bounds.minX + bounds.maxX) / 2,
45
+ y: (bounds.minY + bounds.maxY) / 2,
46
+ z: 0
47
+ },
48
+ size: {
49
+ x: bounds.maxX - bounds.minX,
50
+ y: bounds.maxY - bounds.minY,
51
+ z: this.#outlines.reduce(
52
+ (maximum, outline) => Math.max(maximum, outline.thickness),
53
+ this.#outlines.length ? 0 : fallbackThickness
54
+ )
55
+ },
56
+ material: this.#outlines[0]?.material || 'fr4',
57
+ solderMaskColor: this.#outlines[0]?.solderMaskColor || '',
58
+ silkscreenColor: this.#outlines[0]?.silkscreenColor || '',
59
+ outlines: this.#outlines,
60
+ cutouts: cutouts.map((element) =>
61
+ CircuitJsonIndexer.getElementId(element)
62
+ )
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns aggregate canonical board geometry.
68
+ * @returns {object} Canonical board record.
69
+ */
70
+ get board() {
71
+ return this.#board
72
+ }
73
+
74
+ /**
75
+ * Resolves the containing board surface for one normalized point.
76
+ * @param {'top' | 'bottom'} side Board side.
77
+ * @param {{ x: number, y: number }} point Geometry point.
78
+ * @returns {number} Board surface Z coordinate.
79
+ */
80
+ surfaceZ(side, point) {
81
+ const outline = this.#findOutline(point) || this.#outlines[0]
82
+ const thickness = outline?.thickness || this.#board.size.z
83
+ return side === 'bottom' ? -thickness / 2 : thickness / 2
84
+ }
85
+
86
+ /**
87
+ * Queries the spatial hierarchy and preserves first-authored overlap order.
88
+ * @param {{ x: number, y: number }} point Geometry point.
89
+ * @returns {object | null} Containing outline.
90
+ */
91
+ #findOutline(point) {
92
+ const key = `${point.x}\u0000${point.y}`
93
+ if (this.#pointCache.has(key)) return this.#pointCache.get(key)
94
+ const candidates = Scene3dBoardModel.#pointCandidates(
95
+ this.#pointIndex,
96
+ point
97
+ )
98
+ let outline = null
99
+ for (const entry of candidates) {
100
+ if (Scene3dBoardModel.#contains(entry.outline, point)) {
101
+ outline = entry.outline
102
+ break
103
+ }
104
+ }
105
+ this.#pointCache.set(key, outline)
106
+ return outline
107
+ }
108
+
109
+ /**
110
+ * Maps one source board to canonical outline geometry.
111
+ * @param {object} board Board element.
112
+ * @param {number} fallbackThickness Default thickness.
113
+ * @param {boolean} overrideThickness Whether the default is an override.
114
+ * @returns {object} Canonical outline.
115
+ */
116
+ static #outline(board, fallbackThickness, overrideThickness) {
117
+ const center = Scene3dBoardModel.#point2(
118
+ board.center || { x: board.x, y: board.y }
119
+ )
120
+ const sourceThickness = CircuitJsonUnits.optionalLength(board.thickness)
121
+ const thickness =
122
+ !overrideThickness &&
123
+ sourceThickness !== null &&
124
+ sourceThickness > 0 &&
125
+ sourceThickness <= 1000
126
+ ? sourceThickness
127
+ : fallbackThickness
128
+ return {
129
+ id: CircuitJsonIndexer.getElementId(board),
130
+ center,
131
+ size: {
132
+ x: CircuitJsonUnits.length(board.width, 0),
133
+ y: CircuitJsonUnits.length(board.height, 0)
134
+ },
135
+ thickness,
136
+ material: String(board.material || 'fr4'),
137
+ solderMaskColor: String(board.solder_mask_color || ''),
138
+ silkscreenColor: String(board.silkscreen_color || ''),
139
+ points: Scene3dBoardModel.#points(board.points || board.outline)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Indexes only the first outline for each identical containment geometry.
145
+ * Later identical outlines can never win first-authored point location.
146
+ * @param {object[]} outlines Canonical authored outlines.
147
+ * @returns {{ outline: object, index: number, bounds: object }[]} Point-location entries.
148
+ */
149
+ static #pointEntries(outlines) {
150
+ const entries = []
151
+ const authoredGeometry = new Set()
152
+ for (let index = 0; index < outlines.length; index += 1) {
153
+ const outline = outlines[index]
154
+ const geometry = Scene3dBoardModel.#geometryKey(outline)
155
+ if (authoredGeometry.has(geometry)) continue
156
+ authoredGeometry.add(geometry)
157
+ entries.push({
158
+ outline,
159
+ index,
160
+ bounds: Scene3dBoardModel.#outlineBounds(outline)
161
+ })
162
+ }
163
+ return entries
164
+ }
165
+
166
+ /**
167
+ * Creates a collision-free containment signature from normalized numbers.
168
+ * @param {object} outline Canonical outline.
169
+ * @returns {string} Exact rectangle or authored polygon signature.
170
+ */
171
+ static #geometryKey(outline) {
172
+ if (!outline.points.length) {
173
+ return [
174
+ 'rectangle',
175
+ outline.center.x,
176
+ outline.center.y,
177
+ outline.size.x,
178
+ outline.size.y
179
+ ].join('\u0000')
180
+ }
181
+ const values = ['polygon', outline.points.length]
182
+ for (const point of outline.points) {
183
+ values.push(point.x, point.y)
184
+ }
185
+ return values.join('\u0000')
186
+ }
187
+
188
+ /**
189
+ * Builds an exact adaptive point-location quadtree over outline geometry.
190
+ * @param {{ outline: object, index: number, bounds: object }[]} entries Authored outline entries.
191
+ * @param {object | null} bounds Cell bounds.
192
+ * @param {number} depth Current tree depth.
193
+ * @param {{ nodes: number }} state Global node budget.
194
+ * @returns {object | null} Point-location node.
195
+ */
196
+ static #pointNode(entries, bounds, depth, state) {
197
+ if (!bounds) return null
198
+ state.nodes += 1
199
+ return {
200
+ bounds,
201
+ entries,
202
+ depth,
203
+ state,
204
+ children: [null, null, null, null]
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Returns the exact candidate leaf for one query point.
210
+ * @param {object | null} node Point-location root.
211
+ * @param {{ x: number, y: number }} point Query point.
212
+ * @returns {{ outline: object, index: number, bounds: object }[]} Ordered candidates.
213
+ */
214
+ static #pointCandidates(node, point) {
215
+ if (!node || !Scene3dBoardModel.#boundsContain(node.bounds, point)) {
216
+ return []
217
+ }
218
+ let current = node
219
+ while (
220
+ current.entries.length > POINT_LOCATION_LEAF_SIZE &&
221
+ current.depth < POINT_LOCATION_MAX_DEPTH &&
222
+ current.state.nodes < POINT_LOCATION_MAX_NODES &&
223
+ (current.bounds.minX !== current.bounds.maxX ||
224
+ current.bounds.minY !== current.bounds.maxY)
225
+ ) {
226
+ const midX = (current.bounds.minX + current.bounds.maxX) / 2
227
+ const midY = (current.bounds.minY + current.bounds.maxY) / 2
228
+ const index = (point.x >= midX ? 1 : 0) + (point.y >= midY ? 2 : 0)
229
+ if (!current.children[index]) {
230
+ const cell = Scene3dBoardModel.#childBounds(
231
+ current.bounds,
232
+ midX,
233
+ midY,
234
+ index
235
+ )
236
+ const relevant = []
237
+ for (const entry of current.entries) {
238
+ const relation = Scene3dBoardModel.#cellRelation(
239
+ entry,
240
+ cell
241
+ )
242
+ if (relation === 'disjoint') continue
243
+ relevant.push(entry)
244
+ if (relation === 'contains') break
245
+ }
246
+ current.children[index] = Scene3dBoardModel.#pointNode(
247
+ relevant,
248
+ cell,
249
+ current.depth + 1,
250
+ current.state
251
+ )
252
+ }
253
+ current = current.children[index]
254
+ }
255
+ return current.entries
256
+ }
257
+
258
+ /**
259
+ * Returns one lazily requested quadtree child cell.
260
+ * @param {object} bounds Parent bounds.
261
+ * @param {number} midX Parent X midpoint.
262
+ * @param {number} midY Parent Y midpoint.
263
+ * @param {number} index Child quadrant index.
264
+ * @returns {object} Child bounds.
265
+ */
266
+ static #childBounds(bounds, midX, midY, index) {
267
+ const right = index % 2 === 1
268
+ const top = index >= 2
269
+ return {
270
+ minX: right ? midX : bounds.minX,
271
+ minY: top ? midY : bounds.minY,
272
+ maxX: right ? bounds.maxX : midX,
273
+ maxY: top ? bounds.maxY : midY
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Classifies one outline relative to a point-location cell.
279
+ * @param {{ outline: object, bounds: object }} entry Outline entry.
280
+ * @param {object} cell Cell bounds.
281
+ * @returns {'disjoint' | 'partial' | 'contains'} Exact conservative relation.
282
+ */
283
+ static #cellRelation(entry, cell) {
284
+ if (!Scene3dBoardModel.#boundsOverlap(entry.bounds, cell)) {
285
+ return 'disjoint'
286
+ }
287
+ if (!entry.outline.points.length) {
288
+ return Scene3dBoardModel.#boundsContainBounds(entry.bounds, cell)
289
+ ? 'contains'
290
+ : 'partial'
291
+ }
292
+ const corners = Scene3dBoardModel.#boundsCorners(cell)
293
+ let insideCount = 0
294
+ for (const corner of corners) {
295
+ if (Scene3dBoardModel.#contains(entry.outline, corner)) {
296
+ insideCount += 1
297
+ }
298
+ }
299
+ const boundary = Scene3dBoardModel.#polygonIntersectsBounds(
300
+ entry.outline.points,
301
+ corners
302
+ )
303
+ if (insideCount === corners.length && !boundary) return 'contains'
304
+ if (insideCount || boundary) return 'partial'
305
+ for (const point of entry.outline.points) {
306
+ if (Scene3dBoardModel.#boundsContain(cell, point)) return 'partial'
307
+ }
308
+ return 'disjoint'
309
+ }
310
+
311
+ /**
312
+ * Computes aggregate bounds for indexed outlines.
313
+ * @param {{ bounds: object }[]} entries Indexed outlines.
314
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
315
+ */
316
+ static #entryBounds(entries) {
317
+ const result = {
318
+ minX: Infinity,
319
+ minY: Infinity,
320
+ maxX: -Infinity,
321
+ maxY: -Infinity
322
+ }
323
+ for (const entry of entries) {
324
+ result.minX = Math.min(result.minX, entry.bounds.minX)
325
+ result.minY = Math.min(result.minY, entry.bounds.minY)
326
+ result.maxX = Math.max(result.maxX, entry.bounds.maxX)
327
+ result.maxY = Math.max(result.maxY, entry.bounds.maxY)
328
+ }
329
+ return result
330
+ }
331
+
332
+ /**
333
+ * Tests whether two inclusive bounds overlap.
334
+ * @param {object} left First bounds.
335
+ * @param {object} right Second bounds.
336
+ * @returns {boolean} Whether the bounds overlap.
337
+ */
338
+ static #boundsOverlap(left, right) {
339
+ return !(
340
+ left.maxX < right.minX ||
341
+ left.minX > right.maxX ||
342
+ left.maxY < right.minY ||
343
+ left.minY > right.maxY
344
+ )
345
+ }
346
+
347
+ /**
348
+ * Tests whether outer bounds fully contain inner bounds.
349
+ * @param {object} outer Outer bounds.
350
+ * @param {object} inner Inner bounds.
351
+ * @returns {boolean} Whether the inner bounds are contained.
352
+ */
353
+ static #boundsContainBounds(outer, inner) {
354
+ return (
355
+ inner.minX >= outer.minX &&
356
+ inner.maxX <= outer.maxX &&
357
+ inner.minY >= outer.minY &&
358
+ inner.maxY <= outer.maxY
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Returns cell corners in closed perimeter order.
364
+ * @param {object} bounds Cell bounds.
365
+ * @returns {{ x: number, y: number }[]} Cell corners.
366
+ */
367
+ static #boundsCorners(bounds) {
368
+ return [
369
+ { x: bounds.minX, y: bounds.minY },
370
+ { x: bounds.maxX, y: bounds.minY },
371
+ { x: bounds.maxX, y: bounds.maxY },
372
+ { x: bounds.minX, y: bounds.maxY }
373
+ ]
374
+ }
375
+
376
+ /**
377
+ * Tests whether any polygon edge crosses or touches a cell boundary.
378
+ * @param {{ x: number, y: number }[]} points Polygon points.
379
+ * @param {{ x: number, y: number }[]} corners Cell corners.
380
+ * @returns {boolean} Whether boundaries intersect.
381
+ */
382
+ static #polygonIntersectsBounds(points, corners) {
383
+ for (let pointIndex = 0; pointIndex < points.length; pointIndex += 1) {
384
+ const a = points[pointIndex]
385
+ const b = points[(pointIndex + 1) % points.length]
386
+ for (
387
+ let edgeIndex = 0;
388
+ edgeIndex < corners.length;
389
+ edgeIndex += 1
390
+ ) {
391
+ const c = corners[edgeIndex]
392
+ const d = corners[(edgeIndex + 1) % corners.length]
393
+ if (Scene3dBoardModel.#segmentsIntersect(a, b, c, d)) {
394
+ return true
395
+ }
396
+ }
397
+ }
398
+ return false
399
+ }
400
+
401
+ /**
402
+ * Tests two closed line segments for intersection.
403
+ * @param {{ x: number, y: number }} a First segment start.
404
+ * @param {{ x: number, y: number }} b First segment end.
405
+ * @param {{ x: number, y: number }} c Second segment start.
406
+ * @param {{ x: number, y: number }} d Second segment end.
407
+ * @returns {boolean} Whether the segments intersect.
408
+ */
409
+ static #segmentsIntersect(a, b, c, d) {
410
+ const abC = Scene3dBoardModel.#orientation(a, b, c)
411
+ const abD = Scene3dBoardModel.#orientation(a, b, d)
412
+ const cdA = Scene3dBoardModel.#orientation(c, d, a)
413
+ const cdB = Scene3dBoardModel.#orientation(c, d, b)
414
+ if (abC * abD < 0 && cdA * cdB < 0) return true
415
+ return (
416
+ (abC === 0 && Scene3dBoardModel.#onSegment(c, a, b)) ||
417
+ (abD === 0 && Scene3dBoardModel.#onSegment(d, a, b)) ||
418
+ (cdA === 0 && Scene3dBoardModel.#onSegment(a, c, d)) ||
419
+ (cdB === 0 && Scene3dBoardModel.#onSegment(b, c, d))
420
+ )
421
+ }
422
+
423
+ /**
424
+ * Returns a tolerance-normalized orientation cross product.
425
+ * @param {{ x: number, y: number }} a Segment start.
426
+ * @param {{ x: number, y: number }} b Segment end.
427
+ * @param {{ x: number, y: number }} point Candidate point.
428
+ * @returns {number} Signed orientation or zero.
429
+ */
430
+ static #orientation(a, b, point) {
431
+ const cross =
432
+ (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
433
+ return Math.abs(cross) <= 1e-9 ? 0 : cross
434
+ }
435
+
436
+ /**
437
+ * Tests a point against one axis-aligned bounds record.
438
+ * @param {object} bounds Bounds record.
439
+ * @param {{ x: number, y: number }} point Query point.
440
+ * @returns {boolean} Whether the bounds contain the point.
441
+ */
442
+ static #boundsContain(bounds, point) {
443
+ return (
444
+ point.x >= bounds.minX &&
445
+ point.x <= bounds.maxX &&
446
+ point.y >= bounds.minY &&
447
+ point.y <= bounds.maxY
448
+ )
449
+ }
450
+
451
+ /**
452
+ * Computes exact axis-aligned bounds for one canonical outline.
453
+ * @param {object} outline Canonical outline.
454
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
455
+ */
456
+ static #outlineBounds(outline) {
457
+ if (!outline.points.length) {
458
+ return {
459
+ minX: outline.center.x - outline.size.x / 2,
460
+ minY: outline.center.y - outline.size.y / 2,
461
+ maxX: outline.center.x + outline.size.x / 2,
462
+ maxY: outline.center.y + outline.size.y / 2
463
+ }
464
+ }
465
+ return Scene3dBoardModel.#entryBounds(
466
+ outline.points.map((point) => ({
467
+ bounds: {
468
+ minX: point.x,
469
+ minY: point.y,
470
+ maxX: point.x,
471
+ maxY: point.y
472
+ }
473
+ }))
474
+ )
475
+ }
476
+
477
+ /**
478
+ * Tests whether a point lies within an outline polygon or rectangle.
479
+ * @param {object} outline Canonical outline.
480
+ * @param {{ x: number, y: number }} point Point.
481
+ * @returns {boolean} Whether the outline contains the point.
482
+ */
483
+ static #contains(outline, point) {
484
+ if (!outline.points.length) {
485
+ return (
486
+ point.x >= outline.center.x - outline.size.x / 2 &&
487
+ point.x <= outline.center.x + outline.size.x / 2 &&
488
+ point.y >= outline.center.y - outline.size.y / 2 &&
489
+ point.y <= outline.center.y + outline.size.y / 2
490
+ )
491
+ }
492
+ let inside = false
493
+ for (
494
+ let current = 0, previous = outline.points.length - 1;
495
+ current < outline.points.length;
496
+ previous = current, current += 1
497
+ ) {
498
+ const a = outline.points[current]
499
+ const b = outline.points[previous]
500
+ if (Scene3dBoardModel.#onSegment(point, a, b)) return true
501
+ const crosses =
502
+ a.y > point.y !== b.y > point.y &&
503
+ point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
504
+ if (crosses) inside = !inside
505
+ }
506
+ return inside
507
+ }
508
+
509
+ /**
510
+ * Tests whether a point lies on one polygon edge.
511
+ * @param {{ x: number, y: number }} point Candidate point.
512
+ * @param {{ x: number, y: number }} a Segment start.
513
+ * @param {{ x: number, y: number }} b Segment end.
514
+ * @returns {boolean} Whether the point is on the segment.
515
+ */
516
+ static #onSegment(point, a, b) {
517
+ const cross =
518
+ (point.y - a.y) * (b.x - a.x) - (point.x - a.x) * (b.y - a.y)
519
+ if (Math.abs(cross) > 1e-9) return false
520
+ return (
521
+ point.x >= Math.min(a.x, b.x) &&
522
+ point.x <= Math.max(a.x, b.x) &&
523
+ point.y >= Math.min(a.y, b.y) &&
524
+ point.y <= Math.max(a.y, b.y)
525
+ )
526
+ }
527
+
528
+ /**
529
+ * Computes aggregate bounds from outline rectangles or points.
530
+ * @param {object[]} outlines Canonical outlines.
531
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }} Bounds.
532
+ */
533
+ static #bounds(outlines) {
534
+ if (!outlines.length) {
535
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0 }
536
+ }
537
+ const bounds = {
538
+ minX: Infinity,
539
+ minY: Infinity,
540
+ maxX: -Infinity,
541
+ maxY: -Infinity
542
+ }
543
+ for (const outline of outlines) {
544
+ const points = outline.points.length
545
+ ? outline.points
546
+ : [
547
+ {
548
+ x: outline.center.x - outline.size.x / 2,
549
+ y: outline.center.y - outline.size.y / 2
550
+ },
551
+ {
552
+ x: outline.center.x + outline.size.x / 2,
553
+ y: outline.center.y + outline.size.y / 2
554
+ }
555
+ ]
556
+ for (const point of points) {
557
+ bounds.minX = Math.min(bounds.minX, point.x)
558
+ bounds.minY = Math.min(bounds.minY, point.y)
559
+ bounds.maxX = Math.max(bounds.maxX, point.x)
560
+ bounds.maxY = Math.max(bounds.maxY, point.y)
561
+ }
562
+ }
563
+ return bounds
564
+ }
565
+
566
+ /**
567
+ * Normalizes a point without accepting non-object input.
568
+ * @param {unknown} point Point candidate.
569
+ * @returns {{ x: number, y: number }} Canonical point.
570
+ */
571
+ static #point2(point) {
572
+ return {
573
+ x: CircuitJsonUnits.length(point?.x, 0),
574
+ y: CircuitJsonUnits.length(point?.y, 0)
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Normalizes a board polygon.
580
+ * @param {unknown} points Point candidates.
581
+ * @returns {{ x: number, y: number }[]} Canonical points.
582
+ */
583
+ static #points(points) {
584
+ if (!Array.isArray(points)) return []
585
+ const result = []
586
+ for (const point of points) {
587
+ if (point && typeof point === 'object') {
588
+ result.push(Scene3dBoardModel.#point2(point))
589
+ }
590
+ }
591
+ return result
592
+ }
593
+ }
594
+
595
+ Object.freeze(Scene3dBoardModel.prototype)
596
+ Object.freeze(Scene3dBoardModel)