altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +7 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,935 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ const ROUND_FACTOR = 10000
6
+
7
+ /**
8
+ * Builds deterministic geometry bounds reports for parsed document models.
9
+ */
10
+ export class GeometryBoundsReportBuilder {
11
+ static SCHEMA = 'altium-toolkit.geometry-bounds.a1'
12
+
13
+ /**
14
+ * Builds a geometry bounds report.
15
+ * @param {{ documentModels?: object[], documentModel?: object } | object[] | object} [input]
16
+ * @returns {object}
17
+ */
18
+ static build(input = {}) {
19
+ const documentModels =
20
+ GeometryBoundsReportBuilder.#documentModels(input)
21
+ const entries = []
22
+ let missingBoundsCount = 0
23
+
24
+ documentModels.forEach((model, documentIndex) => {
25
+ missingBoundsCount +=
26
+ GeometryBoundsReportBuilder.#appendDocumentEntries(
27
+ entries,
28
+ model,
29
+ documentIndex
30
+ )
31
+ })
32
+
33
+ return {
34
+ schema: GeometryBoundsReportBuilder.SCHEMA,
35
+ summary: GeometryBoundsReportBuilder.#summary(
36
+ documentModels,
37
+ entries,
38
+ missingBoundsCount
39
+ ),
40
+ entries
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Extracts document models from supported input shapes.
46
+ * @param {{ documentModels?: object[], documentModel?: object } | object[] | object} input
47
+ * @returns {object[]}
48
+ */
49
+ static #documentModels(input) {
50
+ if (Array.isArray(input)) return input
51
+ if (Array.isArray(input.documentModels)) return input.documentModels
52
+ if (input.documentModel) return [input.documentModel]
53
+ if (input.kind || input.schematic || input.pcb) return [input]
54
+ return []
55
+ }
56
+
57
+ /**
58
+ * Appends bounds entries for one document.
59
+ * @param {object[]} entries Destination entries.
60
+ * @param {object} model Parsed document model.
61
+ * @param {number} documentIndex Document index.
62
+ * @returns {number}
63
+ */
64
+ static #appendDocumentEntries(entries, model, documentIndex) {
65
+ let missingBoundsCount = 0
66
+ if (model?.pcb) {
67
+ missingBoundsCount += GeometryBoundsReportBuilder.#appendPcbEntries(
68
+ entries,
69
+ model,
70
+ documentIndex
71
+ )
72
+ }
73
+ if (model?.schematic) {
74
+ missingBoundsCount +=
75
+ GeometryBoundsReportBuilder.#appendSchematicEntries(
76
+ entries,
77
+ model,
78
+ documentIndex
79
+ )
80
+ }
81
+
82
+ return missingBoundsCount
83
+ }
84
+
85
+ /**
86
+ * Appends PCB primitive bounds entries.
87
+ * @param {object[]} entries Destination entries.
88
+ * @param {object} model Parsed document model.
89
+ * @param {number} documentIndex Document index.
90
+ * @returns {number}
91
+ */
92
+ static #appendPcbEntries(entries, model, documentIndex) {
93
+ const pcb = model.pcb || {}
94
+ return GeometryBoundsReportBuilder.#appendFamilies(
95
+ entries,
96
+ model,
97
+ documentIndex,
98
+ 'pcb',
99
+ [
100
+ ['pads', pcb.pads, GeometryBoundsReportBuilder.#padBounds],
101
+ ['vias', pcb.vias, GeometryBoundsReportBuilder.#viaBounds],
102
+ ['tracks', pcb.tracks, GeometryBoundsReportBuilder.#lineBounds],
103
+ ['arcs', pcb.arcs, GeometryBoundsReportBuilder.#arcBounds],
104
+ ['fills', pcb.fills, GeometryBoundsReportBuilder.#boxBounds],
105
+ [
106
+ 'regions',
107
+ pcb.regions,
108
+ GeometryBoundsReportBuilder.#regionBounds
109
+ ],
110
+ ['texts', pcb.texts, GeometryBoundsReportBuilder.#textBounds]
111
+ ]
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Appends schematic primitive bounds entries.
117
+ * @param {object[]} entries Destination entries.
118
+ * @param {object} model Parsed document model.
119
+ * @param {number} documentIndex Document index.
120
+ * @returns {number}
121
+ */
122
+ static #appendSchematicEntries(entries, model, documentIndex) {
123
+ const schematic = model.schematic || {}
124
+ return GeometryBoundsReportBuilder.#appendFamilies(
125
+ entries,
126
+ model,
127
+ documentIndex,
128
+ 'schematic',
129
+ [
130
+ [
131
+ 'lines',
132
+ schematic.lines,
133
+ GeometryBoundsReportBuilder.#lineBounds
134
+ ],
135
+ [
136
+ 'rectangles',
137
+ schematic.rectangles,
138
+ GeometryBoundsReportBuilder.#boxBounds
139
+ ],
140
+ [
141
+ 'roundedRectangles',
142
+ schematic.roundedRectangles,
143
+ GeometryBoundsReportBuilder.#boxBounds
144
+ ],
145
+ [
146
+ 'polygons',
147
+ schematic.polygons,
148
+ GeometryBoundsReportBuilder.#regionBounds
149
+ ],
150
+ [
151
+ 'regions',
152
+ schematic.regions,
153
+ GeometryBoundsReportBuilder.#regionBounds
154
+ ],
155
+ [
156
+ 'ellipses',
157
+ schematic.ellipses,
158
+ GeometryBoundsReportBuilder.#ellipseBounds
159
+ ],
160
+ [
161
+ 'arcs',
162
+ schematic.arcs,
163
+ GeometryBoundsReportBuilder.#arcBounds
164
+ ],
165
+ [
166
+ 'beziers',
167
+ schematic.beziers,
168
+ GeometryBoundsReportBuilder.#regionBounds
169
+ ],
170
+ [
171
+ 'pies',
172
+ schematic.pies,
173
+ GeometryBoundsReportBuilder.#arcBounds
174
+ ],
175
+ [
176
+ 'texts',
177
+ schematic.texts,
178
+ GeometryBoundsReportBuilder.#textBounds
179
+ ],
180
+ [
181
+ 'pins',
182
+ schematic.pins,
183
+ GeometryBoundsReportBuilder.#lineBounds
184
+ ],
185
+ [
186
+ 'ports',
187
+ schematic.ports,
188
+ GeometryBoundsReportBuilder.#boxBounds
189
+ ]
190
+ ]
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Appends multiple primitive families.
196
+ * @param {object[]} entries Destination entries.
197
+ * @param {object} model Parsed document model.
198
+ * @param {number} documentIndex Document index.
199
+ * @param {string} domain Document domain.
200
+ * @param {[string, object[] | undefined, (primitive: object) => object | null][]} families Primitive family descriptors.
201
+ * @returns {number}
202
+ */
203
+ static #appendFamilies(entries, model, documentIndex, domain, families) {
204
+ return families.reduce(
205
+ (total, [family, rows, resolver]) =>
206
+ total +
207
+ GeometryBoundsReportBuilder.#appendFamily(
208
+ entries,
209
+ model,
210
+ documentIndex,
211
+ domain,
212
+ family,
213
+ rows,
214
+ resolver
215
+ ),
216
+ 0
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Appends entries for one primitive family.
222
+ * @param {object[]} entries Destination entries.
223
+ * @param {object} model Parsed document model.
224
+ * @param {number} documentIndex Document index.
225
+ * @param {string} domain Document domain.
226
+ * @param {string} family Primitive family.
227
+ * @param {object[] | undefined} rows Primitive rows.
228
+ * @param {(primitive: object) => object | null} resolver Bounds resolver.
229
+ * @returns {number}
230
+ */
231
+ static #appendFamily(
232
+ entries,
233
+ model,
234
+ documentIndex,
235
+ domain,
236
+ family,
237
+ rows,
238
+ resolver
239
+ ) {
240
+ if (!Array.isArray(rows)) return 0
241
+
242
+ let missingBoundsCount = 0
243
+ rows.forEach((primitive, index) => {
244
+ const bounds = GeometryBoundsReportBuilder.#normalizeBounds(
245
+ resolver(primitive)
246
+ )
247
+ if (!bounds) missingBoundsCount += 1
248
+
249
+ entries.push({
250
+ documentIndex,
251
+ fileName: String(model.fileName || ''),
252
+ domain,
253
+ family,
254
+ index,
255
+ status: bounds ? 'bounded' : 'missing',
256
+ bounds
257
+ })
258
+ })
259
+
260
+ return missingBoundsCount
261
+ }
262
+
263
+ /**
264
+ * Builds one report summary.
265
+ * @param {object[]} documentModels Parsed document models.
266
+ * @param {object[]} entries Bounds entries.
267
+ * @param {number} missingBoundsCount Missing bounds count.
268
+ * @returns {object}
269
+ */
270
+ static #summary(documentModels, entries, missingBoundsCount) {
271
+ const union = GeometryBoundsReportBuilder.#unionBounds(
272
+ entries.map((entry) => entry.bounds).filter(Boolean)
273
+ )
274
+
275
+ return {
276
+ documentCount: documentModels.length,
277
+ entryCount: entries.length,
278
+ missingBoundsCount,
279
+ ...GeometryBoundsReportBuilder.#summaryBounds(union)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Converts union bounds into summary fields.
285
+ * @param {object | null} bounds Union bounds.
286
+ * @returns {object}
287
+ */
288
+ static #summaryBounds(bounds) {
289
+ if (!bounds) {
290
+ return {
291
+ minX: null,
292
+ minY: null,
293
+ maxX: null,
294
+ maxY: null,
295
+ width: 0,
296
+ height: 0
297
+ }
298
+ }
299
+
300
+ return bounds
301
+ }
302
+
303
+ /**
304
+ * Resolves pad bounds.
305
+ * @param {object} primitive Primitive row.
306
+ * @returns {object | null}
307
+ */
308
+ static #padBounds(primitive) {
309
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x'])
310
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y'])
311
+ const width = GeometryBoundsReportBuilder.#number(primitive, [
312
+ 'sizeTopX',
313
+ 'sizeMidX',
314
+ 'sizeBottomX',
315
+ 'sizeX',
316
+ 'width',
317
+ 'diameter'
318
+ ])
319
+ const height = GeometryBoundsReportBuilder.#number(primitive, [
320
+ 'sizeTopY',
321
+ 'sizeMidY',
322
+ 'sizeBottomY',
323
+ 'sizeY',
324
+ 'height',
325
+ 'diameter'
326
+ ])
327
+
328
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
329
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null
330
+
331
+ return GeometryBoundsReportBuilder.#rotatedBoxBounds(
332
+ x,
333
+ y,
334
+ width,
335
+ height,
336
+ GeometryBoundsReportBuilder.#number(primitive, ['rotation']) || 0
337
+ )
338
+ }
339
+
340
+ /**
341
+ * Resolves via bounds.
342
+ * @param {object} primitive Primitive row.
343
+ * @returns {object | null}
344
+ */
345
+ static #viaBounds(primitive) {
346
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x'])
347
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y'])
348
+ const diameter = GeometryBoundsReportBuilder.#number(primitive, [
349
+ 'diameter',
350
+ 'size',
351
+ 'holeDiameter'
352
+ ])
353
+
354
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
355
+ if (!Number.isFinite(diameter)) return null
356
+
357
+ const radius = Math.abs(diameter) / 2
358
+ return {
359
+ minX: x - radius,
360
+ minY: y - radius,
361
+ maxX: x + radius,
362
+ maxY: y + radius
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Resolves line bounds.
368
+ * @param {object} primitive Primitive row.
369
+ * @returns {object | null}
370
+ */
371
+ static #lineBounds(primitive) {
372
+ const x1 = GeometryBoundsReportBuilder.#number(primitive, [
373
+ 'x1',
374
+ 'startX',
375
+ 'fromX'
376
+ ])
377
+ const y1 = GeometryBoundsReportBuilder.#number(primitive, [
378
+ 'y1',
379
+ 'startY',
380
+ 'fromY'
381
+ ])
382
+ const x2 = GeometryBoundsReportBuilder.#number(primitive, [
383
+ 'x2',
384
+ 'endX',
385
+ 'toX'
386
+ ])
387
+ const y2 = GeometryBoundsReportBuilder.#number(primitive, [
388
+ 'y2',
389
+ 'endY',
390
+ 'toY'
391
+ ])
392
+
393
+ if (
394
+ !Number.isFinite(x1) ||
395
+ !Number.isFinite(y1) ||
396
+ !Number.isFinite(x2) ||
397
+ !Number.isFinite(y2)
398
+ ) {
399
+ return null
400
+ }
401
+
402
+ const halfWidth =
403
+ Math.abs(
404
+ GeometryBoundsReportBuilder.#number(primitive, [
405
+ 'width',
406
+ 'lineWidth',
407
+ 'strokeWidth'
408
+ ]) || 0
409
+ ) / 2
410
+ const dx = x2 - x1
411
+ const dy = y2 - y1
412
+ const length = Math.hypot(dx, dy)
413
+ if (!length || !halfWidth) {
414
+ return GeometryBoundsReportBuilder.#boundsFromPoints([
415
+ { x: x1 - halfWidth, y: y1 - halfWidth },
416
+ { x: x1 + halfWidth, y: y1 + halfWidth },
417
+ { x: x2 - halfWidth, y: y2 - halfWidth },
418
+ { x: x2 + halfWidth, y: y2 + halfWidth }
419
+ ])
420
+ }
421
+
422
+ const normalX = (-dy / length) * halfWidth
423
+ const normalY = (dx / length) * halfWidth
424
+ return GeometryBoundsReportBuilder.#boundsFromPoints([
425
+ { x: x1 + normalX, y: y1 + normalY },
426
+ { x: x1 - normalX, y: y1 - normalY },
427
+ { x: x2 + normalX, y: y2 + normalY },
428
+ { x: x2 - normalX, y: y2 - normalY }
429
+ ])
430
+ }
431
+
432
+ /**
433
+ * Resolves rectangular bounds.
434
+ * @param {object} primitive Primitive row.
435
+ * @returns {object | null}
436
+ */
437
+ static #boxBounds(primitive) {
438
+ if (primitive?.bounds) {
439
+ return GeometryBoundsReportBuilder.#boundsObject(primitive.bounds)
440
+ }
441
+
442
+ const x1 = GeometryBoundsReportBuilder.#number(primitive, [
443
+ 'x1',
444
+ 'left',
445
+ 'startX'
446
+ ])
447
+ const y1 = GeometryBoundsReportBuilder.#number(primitive, [
448
+ 'y1',
449
+ 'top',
450
+ 'startY'
451
+ ])
452
+ const x2 = GeometryBoundsReportBuilder.#number(primitive, [
453
+ 'x2',
454
+ 'right',
455
+ 'endX'
456
+ ])
457
+ const y2 = GeometryBoundsReportBuilder.#number(primitive, [
458
+ 'y2',
459
+ 'bottom',
460
+ 'endY'
461
+ ])
462
+
463
+ if (
464
+ Number.isFinite(x1) &&
465
+ Number.isFinite(y1) &&
466
+ Number.isFinite(x2) &&
467
+ Number.isFinite(y2)
468
+ ) {
469
+ return {
470
+ minX: Math.min(x1, x2),
471
+ minY: Math.min(y1, y2),
472
+ maxX: Math.max(x1, x2),
473
+ maxY: Math.max(y1, y2)
474
+ }
475
+ }
476
+
477
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x'])
478
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y'])
479
+ const width = GeometryBoundsReportBuilder.#number(primitive, ['width'])
480
+ const height = GeometryBoundsReportBuilder.#number(primitive, [
481
+ 'height'
482
+ ])
483
+ if (
484
+ Number.isFinite(x) &&
485
+ Number.isFinite(y) &&
486
+ Number.isFinite(width) &&
487
+ Number.isFinite(height)
488
+ ) {
489
+ return GeometryBoundsReportBuilder.#rotatedBoxBounds(
490
+ x,
491
+ y,
492
+ width,
493
+ height,
494
+ GeometryBoundsReportBuilder.#number(primitive, ['rotation']) ||
495
+ 0
496
+ )
497
+ }
498
+
499
+ return null
500
+ }
501
+
502
+ /**
503
+ * Resolves region-like bounds.
504
+ * @param {object} primitive Primitive row.
505
+ * @returns {object | null}
506
+ */
507
+ static #regionBounds(primitive) {
508
+ const pointBounds = GeometryBoundsReportBuilder.#boundsFromPoints(
509
+ GeometryBoundsReportBuilder.#points(primitive)
510
+ )
511
+ return pointBounds || GeometryBoundsReportBuilder.#boxBounds(primitive)
512
+ }
513
+
514
+ /**
515
+ * Resolves ellipse bounds.
516
+ * @param {object} primitive Primitive row.
517
+ * @returns {object | null}
518
+ */
519
+ static #ellipseBounds(primitive) {
520
+ const box = GeometryBoundsReportBuilder.#boxBounds(primitive)
521
+ if (box) return box
522
+
523
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x'])
524
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y'])
525
+ const radiusX = GeometryBoundsReportBuilder.#number(primitive, [
526
+ 'radiusX',
527
+ 'rx',
528
+ 'radius'
529
+ ])
530
+ const radiusY = GeometryBoundsReportBuilder.#number(primitive, [
531
+ 'radiusY',
532
+ 'ry',
533
+ 'radius'
534
+ ])
535
+
536
+ if (
537
+ !Number.isFinite(x) ||
538
+ !Number.isFinite(y) ||
539
+ !Number.isFinite(radiusX) ||
540
+ !Number.isFinite(radiusY)
541
+ ) {
542
+ return null
543
+ }
544
+
545
+ return {
546
+ minX: x - Math.abs(radiusX),
547
+ minY: y - Math.abs(radiusY),
548
+ maxX: x + Math.abs(radiusX),
549
+ maxY: y + Math.abs(radiusY)
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Resolves circular arc bounds.
555
+ * @param {object} primitive Primitive row.
556
+ * @returns {object | null}
557
+ */
558
+ static #arcBounds(primitive) {
559
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x', 'cx'])
560
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y', 'cy'])
561
+ const radius = GeometryBoundsReportBuilder.#number(primitive, [
562
+ 'radius',
563
+ 'r'
564
+ ])
565
+ if (
566
+ !Number.isFinite(x) ||
567
+ !Number.isFinite(y) ||
568
+ !Number.isFinite(radius)
569
+ ) {
570
+ return GeometryBoundsReportBuilder.#regionBounds(primitive)
571
+ }
572
+
573
+ const rawStartAngle = GeometryBoundsReportBuilder.#number(primitive, [
574
+ 'startAngle'
575
+ ])
576
+ const rawEndAngle = GeometryBoundsReportBuilder.#number(primitive, [
577
+ 'endAngle'
578
+ ])
579
+ const startAngle = Number.isFinite(rawStartAngle) ? rawStartAngle : 0
580
+ const endAngle = Number.isFinite(rawEndAngle)
581
+ ? rawEndAngle
582
+ : startAngle + 360
583
+ const halfWidth =
584
+ Math.abs(
585
+ GeometryBoundsReportBuilder.#number(primitive, [
586
+ 'width',
587
+ 'lineWidth',
588
+ 'strokeWidth'
589
+ ]) || 0
590
+ ) / 2
591
+ const points = GeometryBoundsReportBuilder.#arcPoints(
592
+ x,
593
+ y,
594
+ Math.abs(radius),
595
+ startAngle,
596
+ endAngle
597
+ )
598
+ const bounds = GeometryBoundsReportBuilder.#boundsFromPoints(points)
599
+ if (!bounds) return null
600
+
601
+ return {
602
+ minX: bounds.minX - halfWidth,
603
+ minY: bounds.minY - halfWidth,
604
+ maxX: bounds.maxX + halfWidth,
605
+ maxY: bounds.maxY + halfWidth
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Resolves text bounds using model-provided size or deterministic fallback width.
611
+ * @param {object} primitive Primitive row.
612
+ * @returns {object | null}
613
+ */
614
+ static #textBounds(primitive) {
615
+ const box = GeometryBoundsReportBuilder.#boxBounds(primitive)
616
+ if (box) return box
617
+
618
+ const x = GeometryBoundsReportBuilder.#number(primitive, ['x'])
619
+ const y = GeometryBoundsReportBuilder.#number(primitive, ['y'])
620
+ const height = GeometryBoundsReportBuilder.#number(primitive, [
621
+ 'height',
622
+ 'textHeight',
623
+ 'fontSize',
624
+ 'size'
625
+ ])
626
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
627
+ if (!Number.isFinite(height)) return null
628
+
629
+ const explicitWidth = GeometryBoundsReportBuilder.#number(primitive, [
630
+ 'width',
631
+ 'textWidth'
632
+ ])
633
+ const text = String(
634
+ primitive.text ||
635
+ primitive.displayText ||
636
+ primitive.value ||
637
+ primitive.name ||
638
+ ''
639
+ )
640
+ const width = Number.isFinite(explicitWidth)
641
+ ? explicitWidth
642
+ : Math.max(1, text.length) * height * 0.6
643
+
644
+ return GeometryBoundsReportBuilder.#rotatedBoxBounds(
645
+ x,
646
+ y,
647
+ width,
648
+ height,
649
+ GeometryBoundsReportBuilder.#number(primitive, ['rotation']) || 0
650
+ )
651
+ }
652
+
653
+ /**
654
+ * Builds rotated rectangle bounds.
655
+ * @param {number} x Center x.
656
+ * @param {number} y Center y.
657
+ * @param {number} width Width.
658
+ * @param {number} height Height.
659
+ * @param {number} rotation Rotation in degrees.
660
+ * @returns {object}
661
+ */
662
+ static #rotatedBoxBounds(x, y, width, height, rotation) {
663
+ const radians = (rotation * Math.PI) / 180
664
+ const cos = Math.abs(Math.cos(radians))
665
+ const sin = Math.abs(Math.sin(radians))
666
+ const rotatedWidth = Math.abs(width) * cos + Math.abs(height) * sin
667
+ const rotatedHeight = Math.abs(width) * sin + Math.abs(height) * cos
668
+
669
+ return {
670
+ minX: x - rotatedWidth / 2,
671
+ minY: y - rotatedHeight / 2,
672
+ maxX: x + rotatedWidth / 2,
673
+ maxY: y + rotatedHeight / 2
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Builds sampled points for one arc.
679
+ * @param {number} x Center x.
680
+ * @param {number} y Center y.
681
+ * @param {number} radius Radius.
682
+ * @param {number} startAngle Start angle.
683
+ * @param {number} endAngle End angle.
684
+ * @returns {{ x: number, y: number }[]}
685
+ */
686
+ static #arcPoints(x, y, radius, startAngle, endAngle) {
687
+ const points = [
688
+ GeometryBoundsReportBuilder.#pointOnCircle(
689
+ x,
690
+ y,
691
+ radius,
692
+ startAngle
693
+ ),
694
+ GeometryBoundsReportBuilder.#pointOnCircle(x, y, radius, endAngle)
695
+ ]
696
+ const sweep = endAngle - startAngle
697
+ const fullCircle = Math.abs(sweep) >= 360 || sweep === 0
698
+
699
+ for (const angle of [0, 90, 180, 270]) {
700
+ if (
701
+ fullCircle ||
702
+ GeometryBoundsReportBuilder.#angleInSweep(
703
+ angle,
704
+ startAngle,
705
+ endAngle
706
+ )
707
+ ) {
708
+ points.push(
709
+ GeometryBoundsReportBuilder.#pointOnCircle(
710
+ x,
711
+ y,
712
+ radius,
713
+ angle
714
+ )
715
+ )
716
+ }
717
+ }
718
+
719
+ return points
720
+ }
721
+
722
+ /**
723
+ * Returns one point on a circle.
724
+ * @param {number} x Center x.
725
+ * @param {number} y Center y.
726
+ * @param {number} radius Radius.
727
+ * @param {number} angle Angle in degrees.
728
+ * @returns {{ x: number, y: number }}
729
+ */
730
+ static #pointOnCircle(x, y, radius, angle) {
731
+ const radians = (angle * Math.PI) / 180
732
+ return {
733
+ x: x + Math.cos(radians) * radius,
734
+ y: y + Math.sin(radians) * radius
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Returns true when one angle lies inside a positive sweep.
740
+ * @param {number} angle Candidate angle.
741
+ * @param {number} startAngle Start angle.
742
+ * @param {number} endAngle End angle.
743
+ * @returns {boolean}
744
+ */
745
+ static #angleInSweep(angle, startAngle, endAngle) {
746
+ let candidate = GeometryBoundsReportBuilder.#normalizeAngle(angle)
747
+ const start = Number(startAngle)
748
+ let end = Number(endAngle)
749
+
750
+ while (end < start) end += 360
751
+ while (candidate < start) candidate += 360
752
+
753
+ return candidate <= end
754
+ }
755
+
756
+ /**
757
+ * Normalizes one angle to 0..359.999.
758
+ * @param {number} angle Angle in degrees.
759
+ * @returns {number}
760
+ */
761
+ static #normalizeAngle(angle) {
762
+ return ((Number(angle) % 360) + 360) % 360
763
+ }
764
+
765
+ /**
766
+ * Extracts point rows from a primitive.
767
+ * @param {object} primitive Primitive row.
768
+ * @returns {{ x: number, y: number }[]}
769
+ */
770
+ static #points(primitive) {
771
+ const rawPoints =
772
+ primitive?.points ||
773
+ primitive?.vertices ||
774
+ primitive?.controlPoints ||
775
+ primitive?.path ||
776
+ []
777
+ if (!Array.isArray(rawPoints)) return []
778
+
779
+ return rawPoints
780
+ .map((point) => GeometryBoundsReportBuilder.#point(point))
781
+ .filter(Boolean)
782
+ }
783
+
784
+ /**
785
+ * Extracts one point from supported shapes.
786
+ * @param {object | number[]} point Source point.
787
+ * @returns {{ x: number, y: number } | null}
788
+ */
789
+ static #point(point) {
790
+ if (Array.isArray(point)) {
791
+ const x = Number(point[0])
792
+ const y = Number(point[1])
793
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
794
+ }
795
+
796
+ const x = GeometryBoundsReportBuilder.#number(point, ['x'])
797
+ const y = GeometryBoundsReportBuilder.#number(point, ['y'])
798
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
799
+ }
800
+
801
+ /**
802
+ * Builds bounds from point rows.
803
+ * @param {{ x: number, y: number }[]} points Point rows.
804
+ * @returns {object | null}
805
+ */
806
+ static #boundsFromPoints(points) {
807
+ const valid = (points || []).filter(
808
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
809
+ )
810
+ if (!valid.length) return null
811
+
812
+ return {
813
+ minX: Math.min(...valid.map((point) => point.x)),
814
+ minY: Math.min(...valid.map((point) => point.y)),
815
+ maxX: Math.max(...valid.map((point) => point.x)),
816
+ maxY: Math.max(...valid.map((point) => point.y))
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Converts a bounds-like object to report bounds.
822
+ * @param {object} bounds Source bounds.
823
+ * @returns {object | null}
824
+ */
825
+ static #boundsObject(bounds) {
826
+ const minX = GeometryBoundsReportBuilder.#number(bounds, [
827
+ 'minX',
828
+ 'x1',
829
+ 'left'
830
+ ])
831
+ const minY = GeometryBoundsReportBuilder.#number(bounds, [
832
+ 'minY',
833
+ 'y1',
834
+ 'top'
835
+ ])
836
+ const maxX = GeometryBoundsReportBuilder.#number(bounds, [
837
+ 'maxX',
838
+ 'x2',
839
+ 'right'
840
+ ])
841
+ const maxY = GeometryBoundsReportBuilder.#number(bounds, [
842
+ 'maxY',
843
+ 'y2',
844
+ 'bottom'
845
+ ])
846
+
847
+ if (
848
+ !Number.isFinite(minX) ||
849
+ !Number.isFinite(minY) ||
850
+ !Number.isFinite(maxX) ||
851
+ !Number.isFinite(maxY)
852
+ ) {
853
+ return null
854
+ }
855
+
856
+ return { minX, minY, maxX, maxY }
857
+ }
858
+
859
+ /**
860
+ * Normalizes bounds to rounded report fields.
861
+ * @param {object | null} bounds Bounds input.
862
+ * @returns {object | null}
863
+ */
864
+ static #normalizeBounds(bounds) {
865
+ if (!bounds) return null
866
+
867
+ const minX = Number(bounds.minX)
868
+ const minY = Number(bounds.minY)
869
+ const maxX = Number(bounds.maxX)
870
+ const maxY = Number(bounds.maxY)
871
+ if (
872
+ !Number.isFinite(minX) ||
873
+ !Number.isFinite(minY) ||
874
+ !Number.isFinite(maxX) ||
875
+ !Number.isFinite(maxY)
876
+ ) {
877
+ return null
878
+ }
879
+
880
+ const left = Math.min(minX, maxX)
881
+ const top = Math.min(minY, maxY)
882
+ const right = Math.max(minX, maxX)
883
+ const bottom = Math.max(minY, maxY)
884
+
885
+ return {
886
+ minX: GeometryBoundsReportBuilder.#round(left),
887
+ minY: GeometryBoundsReportBuilder.#round(top),
888
+ maxX: GeometryBoundsReportBuilder.#round(right),
889
+ maxY: GeometryBoundsReportBuilder.#round(bottom),
890
+ width: GeometryBoundsReportBuilder.#round(right - left),
891
+ height: GeometryBoundsReportBuilder.#round(bottom - top)
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Builds union bounds from normalized bounds rows.
897
+ * @param {object[]} rows Bounds rows.
898
+ * @returns {object | null}
899
+ */
900
+ static #unionBounds(rows) {
901
+ if (!rows.length) return null
902
+
903
+ return GeometryBoundsReportBuilder.#normalizeBounds({
904
+ minX: Math.min(...rows.map((row) => row.minX)),
905
+ minY: Math.min(...rows.map((row) => row.minY)),
906
+ maxX: Math.max(...rows.map((row) => row.maxX)),
907
+ maxY: Math.max(...rows.map((row) => row.maxY))
908
+ })
909
+ }
910
+
911
+ /**
912
+ * Reads the first finite numeric field.
913
+ * @param {object} source Source object.
914
+ * @param {string[]} keys Candidate keys.
915
+ * @returns {number}
916
+ */
917
+ static #number(source, keys) {
918
+ for (const key of keys) {
919
+ const value = Number(source?.[key])
920
+ if (Number.isFinite(value)) return value
921
+ }
922
+
923
+ return NaN
924
+ }
925
+
926
+ /**
927
+ * Rounds one number for deterministic JSON reports.
928
+ * @param {number} value Numeric value.
929
+ * @returns {number}
930
+ */
931
+ static #round(value) {
932
+ const rounded = Math.round(value * ROUND_FACTOR) / ROUND_FACTOR
933
+ return Object.is(rounded, -0) ? 0 : rounded
934
+ }
935
+ }