altium-toolkit 1.1.35 → 1.1.36

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.
@@ -6,7 +6,7 @@
6
6
  * Assigns selectable keys to static 3D body placements.
7
7
  */
8
8
  export class PcbScene3dStaticBodySelectionKeyBuilder {
9
- static #STATIC_SELECTION_CLUSTER_TOLERANCE_MIL = 20
9
+ static #STATIC_SELECTION_CLUSTER_TOLERANCE_MIL = 2
10
10
 
11
11
  /**
12
12
  * Assigns stable selectable keys while preserving display designators.
@@ -37,7 +37,7 @@ export class PcbScene3dStaticBodySelectionKeyBuilder {
37
37
  })
38
38
 
39
39
  sourceGroups.forEach((indexes) => {
40
- const clusters =
40
+ let clusters =
41
41
  PcbScene3dStaticBodySelectionKeyBuilder.#clusterPlacementIndexes(
42
42
  placements,
43
43
  indexes
@@ -56,7 +56,20 @@ export class PcbScene3dStaticBodySelectionKeyBuilder {
56
56
  return
57
57
  }
58
58
 
59
+ clusters = clusters.flatMap((cluster) =>
60
+ PcbScene3dStaticBodySelectionKeyBuilder.#displaySubclusters(
61
+ placements,
62
+ placementRows,
63
+ cluster
64
+ )
65
+ )
66
+
59
67
  const usedKeys = new Set()
68
+ const displayDesignatorCounts =
69
+ PcbScene3dStaticBodySelectionKeyBuilder.#clusterDisplayDesignatorCounts(
70
+ placements,
71
+ clusters
72
+ )
60
73
  clusters
61
74
  .sort((left, right) =>
62
75
  PcbScene3dStaticBodySelectionKeyBuilder.#compareClusterCenters(
@@ -70,8 +83,17 @@ export class PcbScene3dStaticBodySelectionKeyBuilder {
70
83
  placementRows,
71
84
  cluster.indexes
72
85
  )
86
+ const displayDesignator =
87
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniquePlacementDesignator(
88
+ placements,
89
+ cluster.indexes
90
+ )
73
91
  const selectionKey =
74
92
  matchedDesignator ||
93
+ (displayDesignator &&
94
+ displayDesignatorCounts.get(displayDesignator) === 1
95
+ ? displayDesignator
96
+ : '') ||
75
97
  PcbScene3dStaticBodySelectionKeyBuilder.#uniqueSelectionKey(
76
98
  placements[cluster.indexes[0]],
77
99
  cluster,
@@ -83,9 +105,68 @@ export class PcbScene3dStaticBodySelectionKeyBuilder {
83
105
  })
84
106
  })
85
107
 
108
+ PcbScene3dStaticBodySelectionKeyBuilder.#promoteStrictTouchingOwners(
109
+ placements,
110
+ placementRows
111
+ )
112
+
86
113
  return placements
87
114
  }
88
115
 
116
+ /**
117
+ * Promotes one clear matched owner across physically touching fragments
118
+ * even when the fragments came from different source body identities.
119
+ * @param {{ mountSide?: string, selectionKey?: string }[]} placements Mutable placements.
120
+ * @param {{ matchedComponent?: { designator?: string } | null }[]} placementRows Placement rows.
121
+ */
122
+ static #promoteStrictTouchingOwners(placements, placementRows) {
123
+ const sideGroups = new Map()
124
+
125
+ placements.forEach((placement, index) => {
126
+ const groupKey =
127
+ PcbScene3dStaticBodySelectionKeyBuilder.#placementSideGroupKey(
128
+ placement
129
+ )
130
+ if (!sideGroups.has(groupKey)) {
131
+ sideGroups.set(groupKey, [])
132
+ }
133
+ sideGroups.get(groupKey)?.push(index)
134
+ })
135
+
136
+ sideGroups.forEach((indexes) => {
137
+ PcbScene3dStaticBodySelectionKeyBuilder.#clusterPlacementIndexes(
138
+ placements,
139
+ indexes
140
+ ).forEach((cluster) => {
141
+ if (cluster.indexes.length <= 1) {
142
+ return
143
+ }
144
+
145
+ const matchedDesignator =
146
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniqueMatchedDesignator(
147
+ placementRows,
148
+ cluster.indexes
149
+ )
150
+ if (!matchedDesignator) {
151
+ return
152
+ }
153
+
154
+ cluster.indexes.forEach((index) => {
155
+ placements[index].selectionKey = matchedDesignator
156
+ })
157
+ })
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Builds the grouping key used for strict same-side physical clusters.
163
+ * @param {{ mountSide?: string }} placement Static placement.
164
+ * @returns {string}
165
+ */
166
+ static #placementSideGroupKey(placement) {
167
+ return String(placement?.mountSide || '').trim()
168
+ }
169
+
89
170
  /**
90
171
  * Builds the grouping key used to connect sibling static body shapes.
91
172
  * @param {{ designator?: string, sourceIdentityKey?: string, mountSide?: string }} placement Static placement.
@@ -135,6 +216,122 @@ export class PcbScene3dStaticBodySelectionKeyBuilder {
135
216
  return matchedDesignators.size === 1 ? [...matchedDesignators][0] : ''
136
217
  }
137
218
 
219
+ /**
220
+ * Counts unique display designators across source-body clusters.
221
+ * @param {{ designator?: string }[]} placements Static placements.
222
+ * @param {{ indexes: number[] }[]} clusters Static body clusters.
223
+ * @returns {Map<string, number>}
224
+ */
225
+ static #clusterDisplayDesignatorCounts(placements, clusters) {
226
+ const counts = new Map()
227
+
228
+ clusters.forEach((cluster) => {
229
+ const designator =
230
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniquePlacementDesignator(
231
+ placements,
232
+ cluster.indexes
233
+ )
234
+ if (!designator) {
235
+ return
236
+ }
237
+
238
+ counts.set(designator, (counts.get(designator) || 0) + 1)
239
+ })
240
+
241
+ return counts
242
+ }
243
+
244
+ /**
245
+ * Resolves one unambiguous display designator for a cluster.
246
+ * @param {{ designator?: string }[]} placements Static placements.
247
+ * @param {number[]} indexes Cluster indexes.
248
+ * @returns {string}
249
+ */
250
+ static #uniquePlacementDesignator(placements, indexes) {
251
+ const designators = new Set(
252
+ indexes
253
+ .map((index) =>
254
+ String(placements[index]?.designator || '').trim()
255
+ )
256
+ .filter(Boolean)
257
+ )
258
+
259
+ return designators.size === 1 ? [...designators][0] : ''
260
+ }
261
+
262
+ /**
263
+ * Splits an ambiguous physical cluster by display designator.
264
+ * @param {{ designator?: string }[]} placements Static placements.
265
+ * @param {{ matchedComponent?: { designator?: string } | null }[]} placementRows Placement rows.
266
+ * @param {{ indexes: number[], bounds: object, center: object }} cluster Static body cluster.
267
+ * @returns {{ indexes: number[], bounds: object, center: object }[]}
268
+ */
269
+ static #displaySubclusters(placements, placementRows, cluster) {
270
+ if (
271
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniqueMatchedDesignator(
272
+ placementRows,
273
+ cluster.indexes
274
+ )
275
+ ) {
276
+ return [cluster]
277
+ }
278
+
279
+ const groups = new Map()
280
+ cluster.indexes.forEach((index) => {
281
+ const designator = String(
282
+ placements[index]?.designator || ''
283
+ ).trim()
284
+ if (!designator) {
285
+ return
286
+ }
287
+
288
+ if (!groups.has(designator)) {
289
+ groups.set(designator, [])
290
+ }
291
+ groups.get(designator)?.push(index)
292
+ })
293
+
294
+ return groups.size > 1
295
+ ? [...groups.values()].map((groupIndexes) =>
296
+ PcbScene3dStaticBodySelectionKeyBuilder.#clusterForIndexes(
297
+ placements,
298
+ groupIndexes
299
+ )
300
+ )
301
+ : [cluster]
302
+ }
303
+
304
+ /**
305
+ * Builds a cluster record for explicit placement indexes.
306
+ * @param {object[]} placements Static placements.
307
+ * @param {number[]} indexes Placement indexes.
308
+ * @returns {{ indexes: number[], bounds: object, center: object }}
309
+ */
310
+ static #clusterForIndexes(placements, indexes) {
311
+ const bounds = indexes
312
+ .map((index) =>
313
+ PcbScene3dStaticBodySelectionKeyBuilder.#placementBounds(
314
+ placements[index]
315
+ )
316
+ )
317
+ .reduce((mergedBounds, boundsCandidate) =>
318
+ mergedBounds
319
+ ? PcbScene3dStaticBodySelectionKeyBuilder.#mergeBounds(
320
+ mergedBounds,
321
+ boundsCandidate
322
+ )
323
+ : boundsCandidate
324
+ )
325
+
326
+ return {
327
+ indexes,
328
+ bounds,
329
+ center: PcbScene3dStaticBodySelectionKeyBuilder.#boundsCenter(
330
+ bounds
331
+ )
332
+ }
333
+ }
334
+
138
335
  /**
139
336
  * Clusters static body placements whose horizontal geometry touches.
140
337
  * @param {object[]} placements Static body placements.
@@ -0,0 +1,438 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Promotes complete symmetric source-coordinate chip-body fragments.
7
+ */
8
+ export class PcbScene3dStaticBodySymmetricOwnerPromotion {
9
+ static #CENTER_TOLERANCE_MIL = 2
10
+ static #MAX_DISTANCE_MIL = 18
11
+ static #MIN_PROJECTION_MIL = 4
12
+ static #PROJECTION_TOLERANCE_MIL = 2.5
13
+ static #PERPENDICULAR_TOLERANCE_MIL = 4
14
+
15
+ /**
16
+ * Reassigns complete symmetric source-coordinate fragments as one body.
17
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
18
+ * @param {{ designator?: string, x?: number, y?: number, rotation?: number, layer?: string }[]} components PCB components.
19
+ */
20
+ static promote(placementRows, components = []) {
21
+ const claims = (Array.isArray(components) ? components : [])
22
+ .map((component) =>
23
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#ownerClaim(
24
+ placementRows,
25
+ component
26
+ )
27
+ )
28
+ .filter(Boolean)
29
+ .sort((left, right) => left.score - right.score)
30
+ const assignedIndexes = new Set()
31
+
32
+ claims.forEach((claim) => {
33
+ if (claim.indexes.some((index) => assignedIndexes.has(index))) {
34
+ return
35
+ }
36
+
37
+ claim.indexes.forEach((index) => {
38
+ assignedIndexes.add(index)
39
+ placementRows[index].matchedComponent = claim.component
40
+ placementRows[index].placement =
41
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#withOwner(
42
+ placementRows[index].placement,
43
+ claim.component
44
+ )
45
+ })
46
+ })
47
+ }
48
+
49
+ /**
50
+ * Finds one centered source-coordinate owner claim for a component.
51
+ * @param {{ placement: object }[]} placementRows Placement rows.
52
+ * @param {{ designator?: string, x?: number, y?: number, rotation?: number, layer?: string }} component PCB component.
53
+ * @returns {{ component: object, indexes: number[], score: number } | null}
54
+ */
55
+ static #ownerClaim(placementRows, component) {
56
+ const center =
57
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#componentPosition(
58
+ component
59
+ )
60
+ if (!center || !String(component?.designator || '').trim()) {
61
+ return null
62
+ }
63
+
64
+ const axis =
65
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#componentAxis(
66
+ component
67
+ )
68
+ const ownerSide =
69
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#componentMountSide(
70
+ component
71
+ )
72
+ const candidates =
73
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#sourceCandidates(
74
+ placementRows
75
+ )
76
+ .filter(
77
+ (candidate) =>
78
+ !candidate.row?.placement?.mountSideLocked ||
79
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#normalizeMountSide(
80
+ candidate.row?.placement?.mountSide
81
+ ) === ownerSide
82
+ )
83
+ .map((candidate) => ({
84
+ ...candidate,
85
+ distance: Math.hypot(
86
+ candidate.position.x - center.x,
87
+ candidate.position.y - center.y
88
+ )
89
+ }))
90
+ const centerCandidates = candidates
91
+ .filter(
92
+ (candidate) =>
93
+ candidate.distance <=
94
+ PcbScene3dStaticBodySymmetricOwnerPromotion
95
+ .#CENTER_TOLERANCE_MIL
96
+ )
97
+ .sort((left, right) => left.distance - right.distance)
98
+
99
+ return centerCandidates
100
+ .map((centerCandidate) =>
101
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#bestPairClaim(
102
+ component,
103
+ center,
104
+ axis,
105
+ centerCandidate,
106
+ candidates
107
+ )
108
+ )
109
+ .filter(Boolean)
110
+ .sort((left, right) => left.score - right.score)[0]
111
+ }
112
+
113
+ /**
114
+ * Finds the best opposite matching fragment pair for one center fragment.
115
+ * @param {object} component Owner component.
116
+ * @param {{ x: number, y: number }} center Component center.
117
+ * @param {{ x: number, y: number }} axis Component axis unit vector.
118
+ * @param {{ index: number, distance: number }} centerCandidate Center fragment.
119
+ * @param {object[]} candidates Candidate fragments.
120
+ * @returns {{ component: object, indexes: number[], score: number } | null}
121
+ */
122
+ static #bestPairClaim(
123
+ component,
124
+ center,
125
+ axis,
126
+ centerCandidate,
127
+ candidates
128
+ ) {
129
+ const sideCandidates = candidates
130
+ .filter((candidate) => candidate.index !== centerCandidate.index)
131
+ .map((candidate) => ({
132
+ ...candidate,
133
+ offset: PcbScene3dStaticBodySymmetricOwnerPromotion.#axisOffset(
134
+ candidate.position,
135
+ center,
136
+ axis
137
+ )
138
+ }))
139
+ .filter((candidate) =>
140
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#isSideCandidate(
141
+ candidate
142
+ )
143
+ )
144
+ let bestClaim = null
145
+
146
+ sideCandidates.forEach((negative) => {
147
+ if (negative.offset.projection >= 0) {
148
+ return
149
+ }
150
+
151
+ sideCandidates
152
+ .filter(
153
+ (positive) =>
154
+ positive.offset.projection > 0 &&
155
+ positive.identityKey === negative.identityKey
156
+ )
157
+ .forEach((positive) => {
158
+ const symmetry = Math.abs(
159
+ Math.abs(negative.offset.projection) -
160
+ Math.abs(positive.offset.projection)
161
+ )
162
+ if (
163
+ symmetry >
164
+ PcbScene3dStaticBodySymmetricOwnerPromotion
165
+ .#PROJECTION_TOLERANCE_MIL
166
+ ) {
167
+ return
168
+ }
169
+
170
+ const score =
171
+ centerCandidate.distance +
172
+ symmetry +
173
+ negative.offset.perpendicular +
174
+ positive.offset.perpendicular
175
+ if (!bestClaim || score < bestClaim.score) {
176
+ bestClaim = {
177
+ component,
178
+ indexes: [
179
+ centerCandidate.index,
180
+ negative.index,
181
+ positive.index
182
+ ],
183
+ score
184
+ }
185
+ }
186
+ })
187
+ })
188
+
189
+ return bestClaim
190
+ }
191
+
192
+ /**
193
+ * Builds compact source-coordinate placement candidates.
194
+ * @param {{ placement: object }[]} placementRows Placement rows.
195
+ * @returns {object[]}
196
+ */
197
+ static #sourceCandidates(placementRows) {
198
+ return placementRows
199
+ .map((row, index) => ({
200
+ index,
201
+ row,
202
+ position:
203
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#sourcePosition(
204
+ row?.placement
205
+ ),
206
+ identityKey: String(row?.placement?.sourceIdentityKey || '')
207
+ }))
208
+ .filter(
209
+ (candidate) =>
210
+ candidate.position &&
211
+ candidate.row?.placement?.sourceCoordinateFrame &&
212
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#isCompactRow(
213
+ candidate.row
214
+ )
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Checks whether a projected fragment can be one side of a chip body.
220
+ * @param {{ offset: { projection: number, perpendicular: number } }} candidate Candidate fragment.
221
+ * @returns {boolean}
222
+ */
223
+ static #isSideCandidate(candidate) {
224
+ return (
225
+ Math.abs(candidate.offset.projection) >=
226
+ PcbScene3dStaticBodySymmetricOwnerPromotion
227
+ .#MIN_PROJECTION_MIL &&
228
+ Math.abs(candidate.offset.projection) <=
229
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#MAX_DISTANCE_MIL &&
230
+ candidate.offset.perpendicular <=
231
+ PcbScene3dStaticBodySymmetricOwnerPromotion
232
+ .#PERPENDICULAR_TOLERANCE_MIL
233
+ )
234
+ }
235
+
236
+ /**
237
+ * Checks whether one row is compact enough for symmetric ownership.
238
+ * @param {{ placement: object }} row Placement row.
239
+ * @returns {boolean}
240
+ */
241
+ static #isCompactRow(row) {
242
+ return (
243
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#maxSpan(
244
+ row.placement?.geometry
245
+ ) <= 40
246
+ )
247
+ }
248
+
249
+ /**
250
+ * Resolves the source-space body anchor used for owner proximity.
251
+ * @param {{ bodyPositionMil?: { x?: number, y?: number } }} placement Static placement.
252
+ * @returns {{ x: number, y: number } | null}
253
+ */
254
+ static #sourcePosition(placement) {
255
+ const x = Number(placement?.bodyPositionMil?.x)
256
+ const y = Number(placement?.bodyPositionMil?.y)
257
+
258
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
259
+ }
260
+
261
+ /**
262
+ * Resolves a component source-space center.
263
+ * @param {{ x?: number, y?: number }} component PCB component.
264
+ * @returns {{ x: number, y: number } | null}
265
+ */
266
+ static #componentPosition(component) {
267
+ const x = Number(component?.x)
268
+ const y = Number(component?.y)
269
+
270
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
271
+ }
272
+
273
+ /**
274
+ * Resolves a component axis unit vector.
275
+ * @param {{ rotation?: number }} component PCB component.
276
+ * @returns {{ x: number, y: number }}
277
+ */
278
+ static #componentAxis(component) {
279
+ const angle = (Number(component?.rotation || 0) * Math.PI) / 180
280
+
281
+ return { x: Math.cos(angle), y: Math.sin(angle) }
282
+ }
283
+
284
+ /**
285
+ * Projects a source-space point onto a component axis.
286
+ * @param {{ x: number, y: number }} position Fragment source position.
287
+ * @param {{ x: number, y: number }} center Component source position.
288
+ * @param {{ x: number, y: number }} axis Component axis unit vector.
289
+ * @returns {{ projection: number, perpendicular: number }}
290
+ */
291
+ static #axisOffset(position, center, axis) {
292
+ const dx = position.x - center.x
293
+ const dy = position.y - center.y
294
+
295
+ return {
296
+ projection: dx * axis.x + dy * axis.y,
297
+ perpendicular: Math.abs(-dx * axis.y + dy * axis.x)
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Applies a component owner to a placement.
303
+ * @param {object} placement Static placement.
304
+ * @param {{ designator?: string, layer?: string }} owner Owner component.
305
+ * @returns {object}
306
+ */
307
+ static #withOwner(placement, owner) {
308
+ return {
309
+ ...PcbScene3dStaticBodySymmetricOwnerPromotion.#withMountSide(
310
+ placement,
311
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#componentMountSide(
312
+ owner
313
+ )
314
+ ),
315
+ designator:
316
+ String(owner?.designator || '').trim() || placement?.designator
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Applies a mount side while preserving the placement's XY anchor.
322
+ * @param {object} placement Static placement.
323
+ * @param {string | undefined} mountSide Owner mount side.
324
+ * @returns {object}
325
+ */
326
+ static #withMountSide(placement, mountSide) {
327
+ const currentSide =
328
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#normalizeMountSide(
329
+ placement?.mountSide
330
+ )
331
+ const side =
332
+ PcbScene3dStaticBodySymmetricOwnerPromotion.#normalizeMountSide(
333
+ mountSide || currentSide
334
+ )
335
+ const z = Math.abs(Number(placement?.positionMil?.z || 0))
336
+
337
+ return {
338
+ ...placement,
339
+ mountSide: side,
340
+ positionMil: {
341
+ ...(placement?.positionMil || {}),
342
+ z: side === 'bottom' ? -z : z
343
+ },
344
+ geometry:
345
+ currentSide === side
346
+ ? placement?.geometry
347
+ : PcbScene3dStaticBodySymmetricOwnerPromotion.#mirrorGeometry(
348
+ placement
349
+ )
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Resolves the board side from one component layer.
355
+ * @param {{ layer?: string }} component PCB component.
356
+ * @returns {'top' | 'bottom'}
357
+ */
358
+ static #componentMountSide(component) {
359
+ return String(component?.layer || '')
360
+ .trim()
361
+ .toLowerCase()
362
+ .includes('bottom')
363
+ ? 'bottom'
364
+ : 'top'
365
+ }
366
+
367
+ /**
368
+ * Normalizes a mount side token.
369
+ * @param {string | undefined} mountSide Candidate mount side.
370
+ * @returns {'top' | 'bottom'}
371
+ */
372
+ static #normalizeMountSide(mountSide) {
373
+ return String(mountSide || 'top')
374
+ .trim()
375
+ .toLowerCase() === 'bottom'
376
+ ? 'bottom'
377
+ : 'top'
378
+ }
379
+
380
+ /**
381
+ * Mirrors source-coordinate geometry into the opposite mount-local frame.
382
+ * @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static placement.
383
+ * @returns {object | undefined}
384
+ */
385
+ static #mirrorGeometry(placement) {
386
+ const geometry = placement?.geometry
387
+ if (
388
+ !placement?.sourceCoordinateFrame ||
389
+ !Array.isArray(geometry?.verticesMil)
390
+ ) {
391
+ return geometry
392
+ }
393
+
394
+ return {
395
+ ...geometry,
396
+ verticesMil: geometry.verticesMil.map((vertex) => ({
397
+ x: PcbScene3dStaticBodySymmetricOwnerPromotion.#roundMil(
398
+ Number(vertex?.x || 0)
399
+ ),
400
+ y: PcbScene3dStaticBodySymmetricOwnerPromotion.#roundMil(
401
+ -Number(vertex?.y || 0)
402
+ )
403
+ }))
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Resolves the largest horizontal geometry span.
409
+ * @param {object | undefined} geometry Static geometry.
410
+ * @returns {number}
411
+ */
412
+ static #maxSpan(geometry) {
413
+ const vertices = Array.isArray(geometry?.verticesMil)
414
+ ? geometry.verticesMil
415
+ : []
416
+ if (!vertices.length) {
417
+ return 0
418
+ }
419
+
420
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
421
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
422
+
423
+ return Math.max(
424
+ Math.max(...xs) - Math.min(...xs),
425
+ Math.max(...ys) - Math.min(...ys)
426
+ )
427
+ }
428
+
429
+ /**
430
+ * Rounds one mil value for stable promoted geometry output.
431
+ * @param {number} value Candidate value.
432
+ * @returns {number}
433
+ */
434
+ static #roundMil(value) {
435
+ const rounded = Math.round(Number(value) * 10000) / 10000
436
+ return Object.is(rounded, -0) ? 0 : rounded
437
+ }
438
+ }