altium-toolkit 1.1.30 → 1.1.32

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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Mirrors Altium PCB render models into the same bottom-view frame used by
3
+ * the 3D bottom preset.
4
+ */
5
+ export class AltiumPcbBottomViewMirror {
6
+ /**
7
+ * Mirrors a side-resolved Altium PCB model horizontally around the board.
8
+ * @param {object | null} documentModel Side-resolved PCB document model.
9
+ * @returns {object | null}
10
+ */
11
+ static apply(documentModel) {
12
+ const pcb = documentModel?.pcb
13
+ const outline = pcb?.boardOutline
14
+ if (!pcb || !outline) {
15
+ return documentModel || null
16
+ }
17
+
18
+ const mirrorX = AltiumPcbBottomViewMirror.#buildMirrorX(outline)
19
+
20
+ return {
21
+ ...documentModel,
22
+ pcb: {
23
+ ...pcb,
24
+ boardOutline: AltiumPcbBottomViewMirror.#mirrorOutline(
25
+ outline,
26
+ mirrorX
27
+ ),
28
+ polygons: AltiumPcbBottomViewMirror.#mirrorPolygons(
29
+ pcb.polygons,
30
+ mirrorX
31
+ ),
32
+ fills: AltiumPcbBottomViewMirror.#mirrorFills(
33
+ pcb.fills,
34
+ mirrorX
35
+ ),
36
+ tracks: AltiumPcbBottomViewMirror.#mirrorTracks(
37
+ pcb.tracks,
38
+ mirrorX
39
+ ),
40
+ arcs: AltiumPcbBottomViewMirror.#mirrorArcs(pcb.arcs, mirrorX),
41
+ regions: AltiumPcbBottomViewMirror.#mirrorRegions(
42
+ pcb.regions,
43
+ mirrorX
44
+ ),
45
+ shapeBasedRegions: AltiumPcbBottomViewMirror.#mirrorRegions(
46
+ pcb.shapeBasedRegions,
47
+ mirrorX
48
+ ),
49
+ boardRegions: AltiumPcbBottomViewMirror.#mirrorRegions(
50
+ pcb.boardRegions,
51
+ mirrorX
52
+ ),
53
+ vias: AltiumPcbBottomViewMirror.#mirrorVias(pcb.vias, mirrorX),
54
+ pads: AltiumPcbBottomViewMirror.#mirrorPads(pcb.pads, mirrorX),
55
+ texts: AltiumPcbBottomViewMirror.#mirrorTexts(pcb.texts),
56
+ textGroupTransform:
57
+ AltiumPcbBottomViewMirror.#buildTextGroupTransform(outline),
58
+ components: AltiumPcbBottomViewMirror.#mirrorComponents(
59
+ pcb.components,
60
+ mirrorX
61
+ )
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Builds the board-space X-axis mirror function.
68
+ * @param {{ minX?: number, widthMil?: number }} outline Board outline.
69
+ * @returns {(value: unknown) => number}
70
+ */
71
+ static #buildMirrorX(outline) {
72
+ const minX = Number(outline?.minX || 0)
73
+ const maxX = minX + Number(outline?.widthMil || 0)
74
+
75
+ return (value) => minX + maxX - Number(value || 0)
76
+ }
77
+
78
+ /**
79
+ * Builds the SVG text-layer mirror used by the bottom 3D preset.
80
+ * @param {{ minX?: number, widthMil?: number }} outline Board outline.
81
+ * @returns {{ translateX: number, translateY: number, scaleX: number, scaleY: number }}
82
+ */
83
+ static #buildTextGroupTransform(outline) {
84
+ const minX = Number(outline?.minX || 0)
85
+ const maxX = minX + Number(outline?.widthMil || 0)
86
+
87
+ return {
88
+ translateX: minX + maxX,
89
+ translateY: 0,
90
+ scaleX: -1,
91
+ scaleY: 1
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Mirrors the board outline segment coordinates.
97
+ * @param {object} outline Board outline.
98
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
99
+ * @returns {object}
100
+ */
101
+ static #mirrorOutline(outline, mirrorX) {
102
+ return {
103
+ ...outline,
104
+ segments: AltiumPcbBottomViewMirror.#mirrorSegments(
105
+ outline?.segments,
106
+ mirrorX
107
+ )
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Mirrors polygon segment coordinates.
113
+ * @param {readonly object[] | undefined} polygons Polygon primitives.
114
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
115
+ * @returns {object[]}
116
+ */
117
+ static #mirrorPolygons(polygons, mirrorX) {
118
+ return AltiumPcbBottomViewMirror.#array(polygons).map((polygon) => ({
119
+ ...polygon,
120
+ segments: AltiumPcbBottomViewMirror.#mirrorSegments(
121
+ polygon?.segments,
122
+ mirrorX
123
+ )
124
+ }))
125
+ }
126
+
127
+ /**
128
+ * Mirrors path segment X coordinates.
129
+ * @param {readonly object[] | undefined} segments Path segments.
130
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
131
+ * @returns {object[]}
132
+ */
133
+ static #mirrorSegments(segments, mirrorX) {
134
+ return AltiumPcbBottomViewMirror.#array(segments).map((segment) => ({
135
+ ...segment,
136
+ x1: mirrorX(segment?.x1),
137
+ x2: mirrorX(segment?.x2),
138
+ ...(segment?.cx === null || segment?.cx === undefined
139
+ ? {}
140
+ : { cx: mirrorX(segment.cx) })
141
+ }))
142
+ }
143
+
144
+ /**
145
+ * Mirrors rectangular fill extents.
146
+ * @param {readonly object[] | undefined} fills Fill primitives.
147
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
148
+ * @returns {object[]}
149
+ */
150
+ static #mirrorFills(fills, mirrorX) {
151
+ return AltiumPcbBottomViewMirror.#array(fills).map((fill) => ({
152
+ ...fill,
153
+ x1: mirrorX(fill?.x1),
154
+ x2: mirrorX(fill?.x2),
155
+ points: AltiumPcbBottomViewMirror.#mirrorPointList(
156
+ fill?.points,
157
+ mirrorX
158
+ ),
159
+ holes: AltiumPcbBottomViewMirror.#mirrorHoleLists(
160
+ fill?.holes,
161
+ mirrorX
162
+ )
163
+ }))
164
+ }
165
+
166
+ /**
167
+ * Mirrors line track endpoints.
168
+ * @param {readonly object[] | undefined} tracks Track primitives.
169
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
170
+ * @returns {object[]}
171
+ */
172
+ static #mirrorTracks(tracks, mirrorX) {
173
+ return AltiumPcbBottomViewMirror.#array(tracks).map((track) => ({
174
+ ...track,
175
+ x1: mirrorX(track?.x1),
176
+ x2: mirrorX(track?.x2)
177
+ }))
178
+ }
179
+
180
+ /**
181
+ * Mirrors arc centers and angular spans.
182
+ * @param {readonly object[] | undefined} arcs Arc primitives.
183
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
184
+ * @returns {object[]}
185
+ */
186
+ static #mirrorArcs(arcs, mirrorX) {
187
+ return AltiumPcbBottomViewMirror.#array(arcs).map((arc) => ({
188
+ ...arc,
189
+ x: mirrorX(arc?.x),
190
+ startAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
191
+ arc?.startAngle
192
+ ),
193
+ endAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(arc?.endAngle)
194
+ }))
195
+ }
196
+
197
+ /**
198
+ * Mirrors filled region contours and holes.
199
+ * @param {readonly object[] | undefined} regions Region primitives.
200
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
201
+ * @returns {object[]}
202
+ */
203
+ static #mirrorRegions(regions, mirrorX) {
204
+ return AltiumPcbBottomViewMirror.#array(regions).map((region) => ({
205
+ ...region,
206
+ points: AltiumPcbBottomViewMirror.#mirrorPointList(
207
+ region?.points,
208
+ mirrorX
209
+ ),
210
+ holes: AltiumPcbBottomViewMirror.#mirrorHoleLists(
211
+ region?.holes,
212
+ mirrorX
213
+ ),
214
+ ...(Array.isArray(region?.bendingLines)
215
+ ? {
216
+ bendingLines:
217
+ AltiumPcbBottomViewMirror.#mirrorBendingLines(
218
+ region.bendingLines,
219
+ mirrorX
220
+ )
221
+ }
222
+ : {})
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Mirrors via centers.
228
+ * @param {readonly object[] | undefined} vias Via primitives.
229
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
230
+ * @returns {object[]}
231
+ */
232
+ static #mirrorVias(vias, mirrorX) {
233
+ return AltiumPcbBottomViewMirror.#array(vias).map((via) => ({
234
+ ...via,
235
+ x: mirrorX(via?.x)
236
+ }))
237
+ }
238
+
239
+ /**
240
+ * Mirrors pad centers and X-axis local offsets.
241
+ * @param {readonly object[] | undefined} pads Pad primitives.
242
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
243
+ * @returns {object[]}
244
+ */
245
+ static #mirrorPads(pads, mirrorX) {
246
+ return AltiumPcbBottomViewMirror.#array(pads).map((pad) => ({
247
+ ...pad,
248
+ x: mirrorX(pad?.x),
249
+ rotation: AltiumPcbBottomViewMirror.#mirrorRotation(pad?.rotation),
250
+ holeRotation:
251
+ pad?.holeRotation === null || pad?.holeRotation === undefined
252
+ ? (pad?.holeRotation ?? null)
253
+ : AltiumPcbBottomViewMirror.#mirrorRotation(
254
+ pad.holeRotation
255
+ ),
256
+ ...(pad?.offsetTopX === null || pad?.offsetTopX === undefined
257
+ ? {}
258
+ : { offsetTopX: -Number(pad.offsetTopX || 0) })
259
+ }))
260
+ }
261
+
262
+ /**
263
+ * Preserves PCB text insertion points for the mirrored text layer.
264
+ * @param {readonly object[] | undefined} texts Text primitives.
265
+ * @returns {object[]}
266
+ */
267
+ static #mirrorTexts(texts) {
268
+ return AltiumPcbBottomViewMirror.#array(texts).map((text) => ({
269
+ ...text
270
+ }))
271
+ }
272
+
273
+ /**
274
+ * Mirrors component origins and package rotations.
275
+ * @param {readonly object[] | undefined} components Component records.
276
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
277
+ * @returns {object[]}
278
+ */
279
+ static #mirrorComponents(components, mirrorX) {
280
+ return AltiumPcbBottomViewMirror.#array(components).map(
281
+ (component) => ({
282
+ ...component,
283
+ x: mirrorX(component?.x),
284
+ rotation: AltiumPcbBottomViewMirror.#mirrorRotation(
285
+ component?.rotation
286
+ )
287
+ })
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Mirrors a list of point objects.
293
+ * @param {readonly object[] | undefined} points Point list.
294
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
295
+ * @returns {object[]}
296
+ */
297
+ static #mirrorPointList(points, mirrorX) {
298
+ return AltiumPcbBottomViewMirror.#array(points).map((point) => ({
299
+ ...point,
300
+ x: mirrorX(point?.x),
301
+ ...(point?.centerX === null || point?.centerX === undefined
302
+ ? {}
303
+ : { centerX: mirrorX(point.centerX) }),
304
+ ...(point?.startAngle === null || point?.startAngle === undefined
305
+ ? {}
306
+ : {
307
+ startAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
308
+ point.startAngle
309
+ )
310
+ }),
311
+ ...(point?.endAngle === null || point?.endAngle === undefined
312
+ ? {}
313
+ : {
314
+ endAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
315
+ point.endAngle
316
+ )
317
+ })
318
+ }))
319
+ }
320
+
321
+ /**
322
+ * Mirrors nested hole point lists.
323
+ * @param {readonly object[][] | undefined} holes Hole point lists.
324
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
325
+ * @returns {object[][]}
326
+ */
327
+ static #mirrorHoleLists(holes, mirrorX) {
328
+ return AltiumPcbBottomViewMirror.#array(holes).map((hole) =>
329
+ AltiumPcbBottomViewMirror.#mirrorPointList(hole, mirrorX)
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Mirrors board-region bending-line X endpoints.
335
+ * @param {readonly object[] | undefined} bendingLines Bending lines.
336
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
337
+ * @returns {object[]}
338
+ */
339
+ static #mirrorBendingLines(bendingLines, mirrorX) {
340
+ return AltiumPcbBottomViewMirror.#array(bendingLines).map((line) => ({
341
+ ...line,
342
+ x1:
343
+ line?.x1 === null || line?.x1 === undefined
344
+ ? (line?.x1 ?? null)
345
+ : mirrorX(line.x1),
346
+ x2:
347
+ line?.x2 === null || line?.x2 === undefined
348
+ ? (line?.x2 ?? null)
349
+ : mirrorX(line.x2)
350
+ }))
351
+ }
352
+
353
+ /**
354
+ * Mirrors an object rotation across the board X axis.
355
+ * @param {unknown} angle Rotation angle in degrees.
356
+ * @returns {number}
357
+ */
358
+ static #mirrorRotation(angle) {
359
+ return AltiumPcbBottomViewMirror.#normalizeAngle(
360
+ 180 - Number(angle || 0)
361
+ )
362
+ }
363
+
364
+ /**
365
+ * Mirrors a polar angle across the board X axis.
366
+ * @param {unknown} angle Angle in degrees.
367
+ * @returns {number}
368
+ */
369
+ static #mirrorAngleX(angle) {
370
+ return AltiumPcbBottomViewMirror.#normalizeAngle(
371
+ 180 - Number(angle || 0)
372
+ )
373
+ }
374
+
375
+ /**
376
+ * Normalizes one angle into the [0, 360) range.
377
+ * @param {number} angle Angle in degrees.
378
+ * @returns {number}
379
+ */
380
+ static #normalizeAngle(angle) {
381
+ const normalized = Number(angle || 0) % 360
382
+
383
+ return normalized < 0 ? normalized + 360 : normalized
384
+ }
385
+
386
+ /**
387
+ * Returns an array copy for transform operations.
388
+ * @param {readonly object[] | undefined} value Input collection.
389
+ * @returns {object[]}
390
+ */
391
+ static #array(value) {
392
+ return Array.isArray(value) ? Array.from(value) : []
393
+ }
394
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Preserves Altium component-body anchors that intentionally differ from the
3
+ * resolved footprint owner origin.
4
+ */
5
+ export class AltiumScene3dAuthoredBodyAnchorAdapter {
6
+ static #MIN_OWNER_OFFSET_MIL = 25
7
+ static #BODY_ANCHOR_TOLERANCE_MIL = 5
8
+ static #AUTHORED_SOURCE = 'authored-body-anchor'
9
+ static #AUTHORED_ANCHOR_IDENTITY_PATTERN =
10
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
11
+
12
+ /**
13
+ * Marks off-anchor explicit Altium body placements so the runtime does not
14
+ * recenter them by loaded model bounds.
15
+ * @param {object} sceneDescription Built scene description.
16
+ * @returns {object}
17
+ */
18
+ static apply(sceneDescription) {
19
+ if (
20
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
21
+ 'altium' ||
22
+ !Array.isArray(sceneDescription?.externalPlacements)
23
+ ) {
24
+ return sceneDescription
25
+ }
26
+
27
+ const componentByDesignator =
28
+ AltiumScene3dAuthoredBodyAnchorAdapter.#componentByDesignator(
29
+ sceneDescription?.components
30
+ )
31
+ if (!componentByDesignator.size) {
32
+ return sceneDescription
33
+ }
34
+
35
+ let changed = false
36
+ const externalPlacements = sceneDescription.externalPlacements.map(
37
+ (placement) => {
38
+ const component = componentByDesignator.get(
39
+ String(placement?.designator || '')
40
+ )
41
+ if (
42
+ !AltiumScene3dAuthoredBodyAnchorAdapter.#shouldMarkPlacement(
43
+ placement,
44
+ component,
45
+ sceneDescription?.board
46
+ )
47
+ ) {
48
+ return placement
49
+ }
50
+
51
+ changed = true
52
+ return AltiumScene3dAuthoredBodyAnchorAdapter.#markPlacement(
53
+ placement,
54
+ component,
55
+ sceneDescription?.board
56
+ )
57
+ }
58
+ )
59
+
60
+ return changed
61
+ ? {
62
+ ...sceneDescription,
63
+ externalPlacements
64
+ }
65
+ : sceneDescription
66
+ }
67
+
68
+ /**
69
+ * Builds a designator lookup for scene components.
70
+ * @param {object[] | undefined} components Scene components.
71
+ * @returns {Map<string, object>}
72
+ */
73
+ static #componentByDesignator(components) {
74
+ return new Map(
75
+ (Array.isArray(components) ? components : [])
76
+ .map((component) => [
77
+ String(component?.designator || ''),
78
+ component
79
+ ])
80
+ .filter(([designator]) => designator)
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Checks whether one placement should bypass runtime pad-fallback
86
+ * recentering.
87
+ * @param {object} placement External placement.
88
+ * @param {object | undefined} component Matched scene component.
89
+ * @param {object | undefined} board Scene board.
90
+ * @returns {boolean}
91
+ */
92
+ static #shouldMarkPlacement(placement, component, board) {
93
+ if (
94
+ !component ||
95
+ String(placement?.projection?.source || '').toLowerCase() !==
96
+ 'pad-fallback' ||
97
+ !placement?.positionMil ||
98
+ !placement?.bodyPositionMil
99
+ ) {
100
+ return false
101
+ }
102
+
103
+ const bodyPosition = AltiumScene3dAuthoredBodyAnchorAdapter.#point(
104
+ placement.bodyPositionMil
105
+ )
106
+ const placementPosition =
107
+ AltiumScene3dAuthoredBodyAnchorAdapter.#absolutePlacementPosition(
108
+ placement,
109
+ board
110
+ )
111
+ const ownerPosition =
112
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerPosition(
113
+ component,
114
+ board
115
+ )
116
+ const ownerOffset = AltiumScene3dAuthoredBodyAnchorAdapter.#distance(
117
+ bodyPosition,
118
+ ownerPosition
119
+ )
120
+
121
+ if (
122
+ ownerOffset <
123
+ AltiumScene3dAuthoredBodyAnchorAdapter.#MIN_OWNER_OFFSET_MIL
124
+ ) {
125
+ return false
126
+ }
127
+
128
+ if (
129
+ AltiumScene3dAuthoredBodyAnchorAdapter.#distance(
130
+ placementPosition,
131
+ bodyPosition
132
+ ) >
133
+ AltiumScene3dAuthoredBodyAnchorAdapter.#BODY_ANCHOR_TOLERANCE_MIL
134
+ ) {
135
+ return false
136
+ }
137
+
138
+ return AltiumScene3dAuthoredBodyAnchorAdapter.#hasAuthoredAnchorIdentity(
139
+ placement,
140
+ component
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Checks whether package metadata suggests the offset is an authored
146
+ * connector/mechanical anchor instead of a package source-origin bias.
147
+ * @param {object} placement External placement.
148
+ * @param {object} component Matched scene component.
149
+ * @returns {boolean}
150
+ */
151
+ static #hasAuthoredAnchorIdentity(placement, component) {
152
+ return AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_ANCHOR_IDENTITY_PATTERN.test(
153
+ AltiumScene3dAuthoredBodyAnchorAdapter.#identityText(
154
+ placement,
155
+ component
156
+ )
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Builds searchable package metadata for anchor-preservation checks.
162
+ * @param {object} placement External placement.
163
+ * @param {object} component Matched scene component.
164
+ * @returns {string}
165
+ */
166
+ static #identityText(placement, component) {
167
+ return [
168
+ placement?.designator,
169
+ placement?.externalModel?.name,
170
+ placement?.externalModel?.relativePath,
171
+ component?.pattern,
172
+ component?.source,
173
+ component?.description,
174
+ component?.body?.family,
175
+ ...Object.values(component?.parameters || {})
176
+ ]
177
+ .map((value) => String(value || ''))
178
+ .join(' ')
179
+ }
180
+
181
+ /**
182
+ * Marks one placement as authored-anchor based.
183
+ * @param {object} placement External placement.
184
+ * @param {object} component Matched scene component.
185
+ * @param {object | undefined} board Scene board.
186
+ * @returns {object}
187
+ */
188
+ static #markPlacement(placement, component, board) {
189
+ return {
190
+ ...placement,
191
+ projection: {
192
+ ...(placement.projection || {}),
193
+ source: AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_SOURCE,
194
+ reason: 'Altium component body uses an authored model-origin anchor offset from the owner footprint.'
195
+ },
196
+ modelTransform: {
197
+ ...(placement.modelTransform || {}),
198
+ ownerAnchorOffsetMil:
199
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerAnchorOffset(
200
+ placement,
201
+ component,
202
+ board
203
+ )
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Resolves the source body offset from its owner footprint anchor.
210
+ * @param {object} placement External placement.
211
+ * @param {object} component Matched scene component.
212
+ * @param {object | undefined} board Scene board.
213
+ * @returns {{ x: number, y: number }}
214
+ */
215
+ static #ownerAnchorOffset(placement, component, board) {
216
+ const bodyPosition = AltiumScene3dAuthoredBodyAnchorAdapter.#point(
217
+ placement.bodyPositionMil
218
+ )
219
+ const ownerPosition =
220
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerPosition(
221
+ component,
222
+ board
223
+ )
224
+
225
+ return {
226
+ x: bodyPosition.x - ownerPosition.x,
227
+ y: bodyPosition.y - ownerPosition.y
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Resolves a placement position in board coordinates.
233
+ * @param {object} placement External placement.
234
+ * @param {object | undefined} board Scene board.
235
+ * @returns {{ x: number, y: number }}
236
+ */
237
+ static #absolutePlacementPosition(placement, board) {
238
+ return {
239
+ x:
240
+ Number(placement?.positionMil?.x || 0) +
241
+ Number(board?.centerX || 0),
242
+ y:
243
+ Number(placement?.positionMil?.y || 0) +
244
+ Number(board?.centerY || 0)
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Resolves a scene component position in board coordinates.
250
+ * @param {object} component Scene component.
251
+ * @param {object | undefined} board Scene board.
252
+ * @returns {{ x: number, y: number }}
253
+ */
254
+ static #ownerPosition(component, board) {
255
+ const boardPosition = component?.boardPositionMil
256
+ if (
257
+ Number.isFinite(Number(boardPosition?.x)) &&
258
+ Number.isFinite(Number(boardPosition?.y))
259
+ ) {
260
+ return {
261
+ x: Number(boardPosition.x),
262
+ y: Number(boardPosition.y)
263
+ }
264
+ }
265
+
266
+ return {
267
+ x:
268
+ Number(component?.positionMil?.x || 0) +
269
+ Number(board?.centerX || 0),
270
+ y:
271
+ Number(component?.positionMil?.y || 0) +
272
+ Number(board?.centerY || 0)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Normalizes a partial point.
278
+ * @param {object | undefined} point Source point.
279
+ * @returns {{ x: number, y: number }}
280
+ */
281
+ static #point(point) {
282
+ return {
283
+ x: Number(point?.x || 0),
284
+ y: Number(point?.y || 0)
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Measures XY distance between two points.
290
+ * @param {{ x: number, y: number }} first First point.
291
+ * @param {{ x: number, y: number }} second Second point.
292
+ * @returns {number}
293
+ */
294
+ static #distance(first, second) {
295
+ return Math.hypot(
296
+ Number(first?.x || 0) - Number(second?.x || 0),
297
+ Number(first?.y || 0) - Number(second?.y || 0)
298
+ )
299
+ }
300
+ }