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,89 @@
1
+ import { ToolkitError } from './ToolkitError.mjs'
2
+
3
+ const STAGES = ['detect', 'decode', 'project', 'validate', 'complete']
4
+
5
+ /**
6
+ * Creates ordered shared progress records.
7
+ */
8
+ export class ToolkitProgress {
9
+ /**
10
+ * Normalizes one progress update and validates it against the previous row.
11
+ * @param {Record<string, any>} fields Progress fields.
12
+ * @param {Record<string, any> | null} [previous] Previous progress row.
13
+ * @returns {Record<string, any>} Clone-safe progress row.
14
+ */
15
+ static create(fields = {}, previous = null) {
16
+ const stage = String(fields.stage || '')
17
+ const stageIndex = STAGES.indexOf(stage)
18
+ if (stageIndex < 0) {
19
+ throw ToolkitProgress.#error(
20
+ 'ERR_PROGRESS_STAGE',
21
+ `Unknown progress stage: ${stage || '(empty)'}`
22
+ )
23
+ }
24
+ if (previous?.stage === 'complete') {
25
+ throw ToolkitProgress.#error(
26
+ 'ERR_PROGRESS_TERMINAL',
27
+ 'Progress cannot continue after complete.'
28
+ )
29
+ }
30
+ const previousIndex = previous ? STAGES.indexOf(previous.stage) : -1
31
+ if (previous && (previousIndex < 0 || stageIndex < previousIndex)) {
32
+ throw ToolkitProgress.#error(
33
+ 'ERR_PROGRESS_ORDER',
34
+ `Progress cannot move from ${previous.stage} to ${stage}.`
35
+ )
36
+ }
37
+
38
+ const completed = ToolkitProgress.#count(fields.completed, null)
39
+ const total = ToolkitProgress.#count(fields.total, null)
40
+ if (completed !== null && total !== null && completed > total) {
41
+ throw ToolkitProgress.#error(
42
+ 'ERR_PROGRESS_COUNT',
43
+ 'Progress completed count exceeds total.'
44
+ )
45
+ }
46
+
47
+ const result = { stage }
48
+ if (fields.detail != null && String(fields.detail)) {
49
+ result.detail = String(fields.detail)
50
+ }
51
+ if (fields.completed !== undefined) result.completed = completed
52
+ if (fields.total !== undefined) result.total = total
53
+ if (fields.message != null && String(fields.message)) {
54
+ result.message = String(fields.message)
55
+ }
56
+ return result
57
+ }
58
+
59
+ /**
60
+ * Normalizes a nullable non-negative count.
61
+ * @param {unknown} value Count candidate.
62
+ * @param {number | null} fallback Fallback value.
63
+ * @returns {number | null} Normalized count.
64
+ */
65
+ static #count(value, fallback) {
66
+ if (value == null) return fallback
67
+ const count = Number(value)
68
+ if (!Number.isFinite(count) || count < 0) {
69
+ throw ToolkitProgress.#error(
70
+ 'ERR_PROGRESS_COUNT',
71
+ 'Progress counts must be finite and non-negative.'
72
+ )
73
+ }
74
+ return count
75
+ }
76
+
77
+ /**
78
+ * Creates a progress validation error.
79
+ * @param {string} code Error code.
80
+ * @param {string} message Error message.
81
+ * @returns {ToolkitError} Typed error.
82
+ */
83
+ static #error(code, message) {
84
+ return new ToolkitError(message, {
85
+ code,
86
+ category: 'runtime'
87
+ })
88
+ }
89
+ }
@@ -0,0 +1,246 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ const SIDES = new Set(['top', 'bottom'])
4
+
5
+ /**
6
+ * Normalizes safe reusable PCB interaction options.
7
+ */
8
+ export class CanonicalInteractionOptions {
9
+ /**
10
+ * Normalizes a safe finite CircuitJSON millimeter point.
11
+ * @param {unknown} point Point candidate.
12
+ * @returns {{ x: number, y: number }} Normalized point.
13
+ */
14
+ static point(point) {
15
+ if (!point || typeof point !== 'object') {
16
+ throw CanonicalInteractionOptions.error(
17
+ 'Interaction point must be a plain finite x/y object.'
18
+ )
19
+ }
20
+ let prototype
21
+ let descriptors
22
+ try {
23
+ if (Array.isArray(point)) throw new TypeError('array')
24
+ prototype = Object.getPrototypeOf(point)
25
+ descriptors = Object.getOwnPropertyDescriptors(point)
26
+ } catch {
27
+ throw CanonicalInteractionOptions.error(
28
+ 'Interaction point could not be inspected safely.'
29
+ )
30
+ }
31
+ const x = CanonicalInteractionOptions.#dataValue(descriptors.x)
32
+ const y = CanonicalInteractionOptions.#dataValue(descriptors.y)
33
+ if (
34
+ (prototype !== Object.prototype && prototype !== null) ||
35
+ typeof x !== 'number' ||
36
+ typeof y !== 'number' ||
37
+ !Number.isFinite(x) ||
38
+ !Number.isFinite(y)
39
+ ) {
40
+ throw CanonicalInteractionOptions.error(
41
+ 'Interaction point must be a plain finite x/y object.'
42
+ )
43
+ }
44
+ return { x, y }
45
+ }
46
+
47
+ /**
48
+ * Normalizes one option record over existing defaults.
49
+ * @param {unknown} options Option candidate.
50
+ * @param {Record<string, any>} [defaults] Existing normalized defaults.
51
+ * @returns {{ side: 'top' | 'bottom' | null, tolerance: number, hiddenLayers: string[], hiddenObjects: string[] }} Normalized options.
52
+ */
53
+ static normalize(options = {}, defaults = {}) {
54
+ const record = CanonicalInteractionOptions.#record(options)
55
+ return {
56
+ side: CanonicalInteractionOptions.#side(
57
+ record.side,
58
+ defaults.side ?? null
59
+ ),
60
+ tolerance: CanonicalInteractionOptions.#tolerance(
61
+ record.tolerance,
62
+ defaults.tolerance ?? 0.2
63
+ ),
64
+ hiddenLayers: CanonicalInteractionOptions.#strings(
65
+ record.hiddenLayers,
66
+ defaults.hiddenLayers || [],
67
+ 'hiddenLayers'
68
+ ),
69
+ hiddenObjects: CanonicalInteractionOptions.#strings(
70
+ record.hiddenObjects,
71
+ defaults.hiddenObjects || [],
72
+ 'hiddenObjects'
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Reads supported own data fields without invoking accessors.
79
+ * @param {unknown} value Option candidate.
80
+ * @returns {Record<string, any>} Safe option record.
81
+ */
82
+ static #record(value) {
83
+ if (!value || typeof value !== 'object') {
84
+ throw CanonicalInteractionOptions.error(
85
+ 'Interaction options must be a plain object.'
86
+ )
87
+ }
88
+ let prototype
89
+ let descriptors
90
+ try {
91
+ if (Array.isArray(value)) throw new TypeError('array')
92
+ prototype = Object.getPrototypeOf(value)
93
+ descriptors = Object.getOwnPropertyDescriptors(value)
94
+ } catch {
95
+ throw CanonicalInteractionOptions.error(
96
+ 'Interaction options could not be inspected safely.'
97
+ )
98
+ }
99
+ if (prototype !== Object.prototype && prototype !== null) {
100
+ throw CanonicalInteractionOptions.error(
101
+ 'Interaction options must be a plain object.'
102
+ )
103
+ }
104
+ const allowed = new Set([
105
+ 'hiddenLayers',
106
+ 'hiddenObjects',
107
+ 'side',
108
+ 'tolerance'
109
+ ])
110
+ const result = {}
111
+ for (const key of Reflect.ownKeys(descriptors)) {
112
+ const descriptor = descriptors[key]
113
+ if (
114
+ typeof key !== 'string' ||
115
+ !allowed.has(key) ||
116
+ descriptor.enumerable !== true ||
117
+ descriptor.get ||
118
+ descriptor.set
119
+ ) {
120
+ throw CanonicalInteractionOptions.error(
121
+ 'Interaction options contain an unsupported field.'
122
+ )
123
+ }
124
+ result[key] = descriptor.value
125
+ }
126
+ return result
127
+ }
128
+
129
+ /**
130
+ * Normalizes one optional board side.
131
+ * @param {unknown} value Side candidate.
132
+ * @param {'top' | 'bottom' | null} fallback Fallback side.
133
+ * @returns {'top' | 'bottom' | null} Normalized side.
134
+ */
135
+ static #side(value, fallback) {
136
+ const side = value === undefined ? fallback : value
137
+ if (side !== null && !SIDES.has(side)) {
138
+ throw CanonicalInteractionOptions.error(
139
+ 'Interaction side must be top or bottom.'
140
+ )
141
+ }
142
+ return side
143
+ }
144
+
145
+ /**
146
+ * Normalizes one nonnegative interaction tolerance.
147
+ * @param {unknown} value Tolerance candidate.
148
+ * @param {number} fallback Fallback tolerance.
149
+ * @returns {number} Normalized tolerance.
150
+ */
151
+ static #tolerance(value, fallback) {
152
+ const tolerance = value === undefined ? fallback : value
153
+ if (
154
+ typeof tolerance !== 'number' ||
155
+ !Number.isFinite(tolerance) ||
156
+ tolerance < 0 ||
157
+ tolerance > 1000000
158
+ ) {
159
+ throw CanonicalInteractionOptions.error(
160
+ 'Interaction tolerance must be a bounded nonnegative number.'
161
+ )
162
+ }
163
+ return tolerance
164
+ }
165
+
166
+ /**
167
+ * Normalizes one unique string-list option through data descriptors.
168
+ * @param {unknown} value Array candidate.
169
+ * @param {string[]} fallback Fallback values.
170
+ * @param {string} name Option name.
171
+ * @returns {string[]} Normalized strings.
172
+ */
173
+ static #strings(value, fallback, name) {
174
+ if (value === undefined) return [...fallback]
175
+ let descriptors
176
+ try {
177
+ if (
178
+ !Array.isArray(value) ||
179
+ Object.getPrototypeOf(value) !== Array.prototype
180
+ ) {
181
+ throw new TypeError('array')
182
+ }
183
+ descriptors = Object.getOwnPropertyDescriptors(value)
184
+ } catch {
185
+ throw CanonicalInteractionOptions.error(
186
+ `Interaction ${name} must be a plain string array.`
187
+ )
188
+ }
189
+ const length = descriptors.length?.value
190
+ if (!Number.isSafeInteger(length) || length < 0 || length > 4096) {
191
+ throw CanonicalInteractionOptions.error(
192
+ `Interaction ${name} must be a bounded string array.`
193
+ )
194
+ }
195
+ const result = []
196
+ for (let index = 0; index < length; index += 1) {
197
+ const descriptor = descriptors[String(index)]
198
+ if (
199
+ !descriptor ||
200
+ descriptor.get ||
201
+ descriptor.set ||
202
+ typeof descriptor.value !== 'string' ||
203
+ !descriptor.value.trim()
204
+ ) {
205
+ throw CanonicalInteractionOptions.error(
206
+ `Interaction ${name} must contain non-empty strings.`
207
+ )
208
+ }
209
+ result.push(descriptor.value.trim())
210
+ }
211
+ const allowed = new Set([
212
+ 'length',
213
+ ...Array.from({ length }, (_entry, index) => String(index))
214
+ ])
215
+ if (Reflect.ownKeys(descriptors).some((key) => !allowed.has(key))) {
216
+ throw CanonicalInteractionOptions.error(
217
+ `Interaction ${name} must be a plain string array.`
218
+ )
219
+ }
220
+ return [...new Set(result)]
221
+ }
222
+
223
+ /**
224
+ * Returns a descriptor's own data value without invoking accessors.
225
+ * @param {PropertyDescriptor | undefined} descriptor Field descriptor.
226
+ * @returns {unknown} Data value or undefined.
227
+ */
228
+ static #dataValue(descriptor) {
229
+ return descriptor && !descriptor.get && !descriptor.set
230
+ ? descriptor.value
231
+ : undefined
232
+ }
233
+
234
+ /**
235
+ * Creates one typed interaction-option error.
236
+ * @param {string} message Failure message.
237
+ * @returns {ToolkitError} Typed error.
238
+ */
239
+ static error(message) {
240
+ return new ToolkitError(message, {
241
+ code: 'ERR_INTERACTION_OPTIONS',
242
+ category: 'validation',
243
+ format: 'circuitjson'
244
+ })
245
+ }
246
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Resolves conservative broad-phase bounds from every exact geometry signal.
3
+ */
4
+ export class PcbInteractionBounds {
5
+ /**
6
+ * Unions stored, point-list, segment-stroke, and rotated shape bounds.
7
+ * @param {object} primitive Prepared PCB primitive.
8
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null} Conservative bounds.
9
+ */
10
+ static resolve(primitive) {
11
+ const stored = PcbInteractionBounds.#bounds(primitive?.bounds)
12
+ const points = (
13
+ Array.isArray(primitive?.points) ? primitive.points : []
14
+ )
15
+ .map(PcbInteractionBounds.#point)
16
+ .filter(Boolean)
17
+ const pointBounds = points.length
18
+ ? PcbInteractionBounds.#pointBounds(points)
19
+ : null
20
+ const segment = PcbInteractionBounds.#segmentBounds(primitive)
21
+ const shape = PcbInteractionBounds.#rotatedShapeBounds(primitive)
22
+ const signals = [stored, pointBounds, segment, shape].filter(Boolean)
23
+ return signals.length ? PcbInteractionBounds.#merge(signals) : null
24
+ }
25
+
26
+ /**
27
+ * Resolves stroke-expanded explicit segment bounds.
28
+ * @param {object} primitive Primitive candidate.
29
+ * @returns {object | null} Segment bounds.
30
+ */
31
+ static #segmentBounds(primitive) {
32
+ const start = PcbInteractionBounds.#point({
33
+ x: primitive?.x1,
34
+ y: primitive?.y1
35
+ })
36
+ const end = PcbInteractionBounds.#point({
37
+ x: primitive?.x2,
38
+ y: primitive?.y2
39
+ })
40
+ if (!start || !end) return null
41
+ const halfWidth = Math.max(
42
+ PcbInteractionBounds.#number(primitive?.width, 0) / 2,
43
+ 0
44
+ )
45
+ return {
46
+ minX: Math.min(start.x, end.x) - halfWidth,
47
+ minY: Math.min(start.y, end.y) - halfWidth,
48
+ maxX: Math.max(start.x, end.x) + halfWidth,
49
+ maxY: Math.max(start.y, end.y) + halfWidth
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolves an axis-aligned envelope for one rotated center-size shape.
55
+ * @param {object} primitive Primitive candidate.
56
+ * @returns {object | null} Rotated shape bounds.
57
+ */
58
+ static #rotatedShapeBounds(primitive) {
59
+ const center = PcbInteractionBounds.#point(primitive)
60
+ const width = PcbInteractionBounds.#finite(
61
+ primitive?.width ?? primitive?.diameter
62
+ )
63
+ const height = PcbInteractionBounds.#finite(
64
+ primitive?.height ?? primitive?.diameter ?? primitive?.width
65
+ )
66
+ if (!center || width === null || height === null) return null
67
+ const radians =
68
+ (PcbInteractionBounds.#number(primitive?.rotation, 0) * Math.PI) /
69
+ 180
70
+ const cos = Math.abs(Math.cos(radians))
71
+ const sin = Math.abs(Math.sin(radians))
72
+ const halfWidth = (Math.abs(width) * cos + Math.abs(height) * sin) / 2
73
+ const halfHeight = (Math.abs(width) * sin + Math.abs(height) * cos) / 2
74
+ return {
75
+ minX: center.x - halfWidth,
76
+ minY: center.y - halfHeight,
77
+ maxX: center.x + halfWidth,
78
+ maxY: center.y + halfHeight
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolves minimal bounds for normalized points.
84
+ * @param {{ x: number, y: number }[]} points Normalized points.
85
+ * @returns {object} Point bounds.
86
+ */
87
+ static #pointBounds(points) {
88
+ return points.reduce(
89
+ (bounds, point) => ({
90
+ minX: Math.min(bounds.minX, point.x),
91
+ minY: Math.min(bounds.minY, point.y),
92
+ maxX: Math.max(bounds.maxX, point.x),
93
+ maxY: Math.max(bounds.maxY, point.y)
94
+ }),
95
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Unions normalized bounds rows.
101
+ * @param {object[]} rows Bounds rows.
102
+ * @returns {object} Merged bounds.
103
+ */
104
+ static #merge(rows) {
105
+ return rows.reduce(
106
+ (bounds, row) => ({
107
+ minX: Math.min(bounds.minX, row.minX),
108
+ minY: Math.min(bounds.minY, row.minY),
109
+ maxX: Math.max(bounds.maxX, row.maxX),
110
+ maxY: Math.max(bounds.maxY, row.maxY)
111
+ }),
112
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Normalizes a point candidate.
118
+ * @param {unknown} value Point candidate.
119
+ * @returns {{ x: number, y: number } | null} Point or null.
120
+ */
121
+ static #point(value) {
122
+ const x = PcbInteractionBounds.#finite(value?.x)
123
+ const y = PcbInteractionBounds.#finite(value?.y)
124
+ return x === null || y === null ? null : { x, y }
125
+ }
126
+
127
+ /**
128
+ * Normalizes ordered rectangle bounds.
129
+ * @param {unknown} value Bounds candidate.
130
+ * @returns {object | null} Bounds or null.
131
+ */
132
+ static #bounds(value) {
133
+ const minX = PcbInteractionBounds.#finite(value?.minX)
134
+ const minY = PcbInteractionBounds.#finite(value?.minY)
135
+ const maxX = PcbInteractionBounds.#finite(value?.maxX)
136
+ const maxY = PcbInteractionBounds.#finite(value?.maxY)
137
+ return minX !== null &&
138
+ minY !== null &&
139
+ maxX !== null &&
140
+ maxY !== null &&
141
+ minX <= maxX &&
142
+ minY <= maxY
143
+ ? { minX, minY, maxX, maxY }
144
+ : null
145
+ }
146
+
147
+ /**
148
+ * Converts one value to a finite number or null.
149
+ * @param {unknown} value Number candidate.
150
+ * @returns {number | null} Finite number or null.
151
+ */
152
+ static #finite(value) {
153
+ if (value === undefined || value === null || value === '') return null
154
+ const number = Number(value)
155
+ return Number.isFinite(number) ? number : null
156
+ }
157
+
158
+ /**
159
+ * Converts one value to a finite number with fallback.
160
+ * @param {unknown} value Number candidate.
161
+ * @param {number} fallback Fallback number.
162
+ * @returns {number} Finite number.
163
+ */
164
+ static #number(value, fallback) {
165
+ return PcbInteractionBounds.#finite(value) ?? fallback
166
+ }
167
+ }