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.
- package/README.md +34 -5
- package/docs/api.md +171 -23
- package/docs/model-format.md +192 -20
- package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
- package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
- package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
- package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
- package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
- package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
- package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
- package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
- package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
- package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
- package/docs/testing.md +7 -0
- package/examples/README.md +21 -0
- package/examples/cli-utils.mjs +148 -0
- package/examples/corpus-smoke.mjs +523 -0
- package/examples/extract-bom.mjs +47 -0
- package/examples/generate-pnp.mjs +59 -0
- package/examples/inspect-board.mjs +70 -0
- package/examples/inspect-schematic.mjs +406 -0
- package/examples/library-catalog.mjs +115 -0
- package/examples/net-report.mjs +61 -0
- package/examples/validate-library.mjs +59 -0
- package/package.json +1 -1
- package/spec/library-scope.md +5 -0
- package/src/core/BinaryReader.mjs +213 -2
- package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
- package/src/core/altium/AltiumParser.mjs +357 -16
- package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
- package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
- package/src/core/altium/AltiumUnits.mjs +205 -0
- package/src/core/altium/AsciiRecordParser.mjs +52 -11
- package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
- package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
- package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
- package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
- package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
- package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
- package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
- package/src/core/altium/NormalizedModelSchema.mjs +3 -31
- package/src/core/altium/ParameterCollection.mjs +431 -0
- package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
- package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
- package/src/core/altium/ParserErrors.mjs +90 -0
- package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
- package/src/core/altium/ParserUtils.mjs +24 -0
- package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
- package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
- package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
- package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
- package/src/core/altium/PcbLayerGroups.mjs +308 -0
- package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
- package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
- package/src/core/altium/PcbLibModelParser.mjs +20 -4
- package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
- package/src/core/altium/PcbModelParser.mjs +223 -4
- package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
- package/src/core/altium/PcbStreamExtractor.mjs +130 -6
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
- package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
- package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
- package/src/core/altium/SchLibModelParser.mjs +840 -0
- package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
- package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
- package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
- package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
- package/src/core/altium/SchematicHarnessParser.mjs +9 -3
- package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
- package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
- package/src/core/altium/SchematicPinParser.mjs +99 -65
- package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
- package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
- package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
- package/src/core/altium/SchematicSheetParser.mjs +8 -2
- package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
- package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
- package/src/core/altium/SchematicTextParser.mjs +28 -12
- package/src/core/altium/SchematicTextRunParser.mjs +81 -0
- package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
- package/src/core/altium/SourceBundleExporter.mjs +156 -0
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
- package/src/core/altium/SourceComponentClient.mjs +239 -0
- package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
- package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
- package/src/parser.mjs +43 -1
- package/src/renderers.mjs +1 -0
- package/src/styles/altium-renderers.css +6 -6
- package/src/ui/PcbArcUtils.mjs +19 -2
- package/src/ui/PcbScene3dBuilder.mjs +202 -20
- package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
- package/src/ui/SchematicColorResolver.mjs +185 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
- package/src/ui/SchematicLineColorResolver.mjs +88 -0
- package/src/ui/SchematicNoteRenderer.mjs +5 -1
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
- package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
- package/src/ui/SchematicShapeRenderer.mjs +86 -17
- package/src/ui/SchematicSvgRenderer.mjs +980 -58
- 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
|
+
}
|