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.
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
6
+ import { PcbScene3dStaticBodyOwnerPromotion } from './PcbScene3dStaticBodyOwnerPromotion.mjs'
6
7
  import { PcbScene3dStaticBodyRecovery } from './PcbScene3dStaticBodyRecovery.mjs'
7
8
  import { PcbScene3dStaticBodySelectionKeyBuilder } from './PcbScene3dStaticBodySelectionKeyBuilder.mjs'
8
9
 
@@ -21,6 +22,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
21
22
  static #SOURCE_COORDINATE_MIRROR_MIN_OFFSET_MIL = 20
22
23
  static #GENERIC_MECHANICAL_IDENTITY_TOKENS = new Set([
23
24
  'can',
25
+ 'clip',
24
26
  'cover',
25
27
  'enclosure',
26
28
  'frame',
@@ -66,6 +68,12 @@ export class PcbScene3dStaticBodyPlacementBuilder {
66
68
  )
67
69
  .filter(Boolean)
68
70
 
71
+ PcbScene3dStaticBodyOwnerPromotion.promote(
72
+ placementRows,
73
+ components,
74
+ board
75
+ )
76
+
69
77
  return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
70
78
  }
71
79
 
@@ -183,6 +191,12 @@ export class PcbScene3dStaticBodyPlacementBuilder {
183
191
  Number(thicknessMil || 0) / 2 +
184
192
  (Number.isFinite(standoffMil) ? standoffMil : 0) +
185
193
  heightMil / 2
194
+ const renderGeometry =
195
+ PcbScene3dStaticBodyPlacementBuilder.#placementGeometry(
196
+ geometry,
197
+ staticGeometry,
198
+ mountSide
199
+ )
186
200
 
187
201
  return {
188
202
  designator:
@@ -194,11 +208,21 @@ export class PcbScene3dStaticBodyPlacementBuilder {
194
208
  PcbScene3dStaticBodyPlacementBuilder.#sourceIdentityKey(
195
209
  componentBody
196
210
  ),
197
- mountSide,
198
- rotationDeg: PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
199
- Number(componentBody.rotationDeg || 0) +
200
- Number(matchedComponent?.rotation || 0)
211
+ sourceCoordinateFrame: Boolean(
212
+ staticGeometry.sourceCoordinateFrame
201
213
  ),
214
+ mountSideLocked:
215
+ PcbScene3dStaticBodyPlacementBuilder.#hasExplicitMountSide(
216
+ componentBody,
217
+ matchedComponent
218
+ ),
219
+ mountSide,
220
+ rotationDeg:
221
+ PcbScene3dStaticBodyPlacementBuilder.#placementRotationDeg(
222
+ componentBody,
223
+ matchedComponent,
224
+ staticGeometry
225
+ ),
202
226
  positionMil: {
203
227
  x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
204
228
  Number(sourcePosition.x || 0) - Number(board.centerX || 0)
@@ -217,7 +241,107 @@ export class PcbScene3dStaticBodyPlacementBuilder {
217
241
  ...PcbScene3dStaticBodyPlacementBuilder.#displayMetadata(
218
242
  componentBody
219
243
  ),
220
- geometry
244
+ geometry: renderGeometry
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Checks whether one body carries explicit top/bottom side evidence.
250
+ * @param {{ componentIndex?: number | string, layer?: string, standoffHeightMil?: number | string | null }} componentBody Component body.
251
+ * @param {{ layer?: string } | null} matchedComponent Matched owner component.
252
+ * @returns {boolean}
253
+ */
254
+ static #hasExplicitMountSide(componentBody, matchedComponent) {
255
+ if (
256
+ matchedComponent &&
257
+ Number.isInteger(Number(componentBody?.componentIndex))
258
+ ) {
259
+ return true
260
+ }
261
+
262
+ const layer = String(componentBody?.layer || '').trim()
263
+ if (!layer) {
264
+ const standoffMil = Number(componentBody?.standoffHeightMil)
265
+ return Number.isFinite(standoffMil) && standoffMil < 0
266
+ }
267
+
268
+ if (
269
+ !PcbScene3dStaticBodyPlacementBuilder.#isAuthoredMechanicalBody(
270
+ componentBody
271
+ )
272
+ ) {
273
+ return false
274
+ }
275
+
276
+ if (/(^|[^a-z])(top|bottom)([^a-z]|$)/i.test(layer)) {
277
+ return true
278
+ }
279
+
280
+ return /^MECHANICAL\s*\d+$/i.test(layer)
281
+ }
282
+
283
+ /**
284
+ * Checks whether one body identity looks like authored board mechanics.
285
+ * @param {{ identifier?: string, name?: string, modelTypeName?: string }} componentBody Component body.
286
+ * @returns {boolean}
287
+ */
288
+ static #isAuthoredMechanicalBody(componentBody) {
289
+ return PcbScene3dStaticBodyPlacementBuilder.#identityTokens(
290
+ componentBody
291
+ ).some((token) =>
292
+ PcbScene3dStaticBodyPlacementBuilder.#GENERIC_MECHANICAL_IDENTITY_TOKENS.has(
293
+ token
294
+ )
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Resolves the rendered rotation for one static shape body.
300
+ * @param {{ rotationDeg?: number }} componentBody Component body.
301
+ * @param {{ rotation?: number } | null} matchedComponent Matched owner component.
302
+ * @param {{ sourceCoordinateFrame?: boolean }} normalizedStaticGeometry Normalized static geometry context.
303
+ * @returns {number}
304
+ */
305
+ static #placementRotationDeg(
306
+ componentBody,
307
+ matchedComponent,
308
+ normalizedStaticGeometry
309
+ ) {
310
+ const ownerRotation = normalizedStaticGeometry?.sourceCoordinateFrame
311
+ ? 0
312
+ : Number(matchedComponent?.rotation || 0)
313
+
314
+ return PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
315
+ Number(componentBody.rotationDeg || 0) + ownerRotation
316
+ )
317
+ }
318
+
319
+ /**
320
+ * Converts normalized static geometry into the renderer mount-local frame.
321
+ * @param {object} geometry Normalized render geometry.
322
+ * @param {{ sourceCoordinateFrame?: boolean }} normalizedStaticGeometry Normalized static geometry context.
323
+ * @param {string} mountSide Resolved mount side.
324
+ * @returns {object}
325
+ */
326
+ static #placementGeometry(geometry, normalizedStaticGeometry, mountSide) {
327
+ if (
328
+ !normalizedStaticGeometry?.sourceCoordinateFrame ||
329
+ String(mountSide || 'top').toLowerCase() !== 'bottom' ||
330
+ !Array.isArray(geometry?.verticesMil)
331
+ ) {
332
+ return geometry
333
+ }
334
+
335
+ return {
336
+ ...geometry,
337
+ verticesMil: geometry.verticesMil.map((vertex) => ({
338
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
339
+ Number(vertex?.x || 0)
340
+ ),
341
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
342
+ -Number(vertex?.y || 0)
343
+ )
344
+ }))
221
345
  }
222
346
  }
223
347
 
@@ -226,19 +350,24 @@ export class PcbScene3dStaticBodyPlacementBuilder {
226
350
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
227
351
  * @param {object | undefined} geometry Static geometry.
228
352
  * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
229
- * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number } }}
353
+ * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number }, sourceCoordinateFrame: boolean }}
230
354
  */
231
355
  static #normalizeStaticGeometry(componentBody, geometry, matchedComponent) {
232
356
  if (
233
357
  String(geometry?.kind || '').toLowerCase() !== 'extruded-polygon' ||
234
358
  !Array.isArray(geometry?.verticesMil) ||
235
- geometry.verticesMil.length < 3 ||
359
+ geometry.verticesMil.length < 3
360
+ ) {
361
+ return { geometry, sourceCoordinateFrame: false }
362
+ }
363
+
364
+ if (
236
365
  !PcbScene3dStaticBodyPlacementBuilder.#usesSourceCoordinateFrame(
237
366
  componentBody,
238
367
  geometry.verticesMil
239
368
  )
240
369
  ) {
241
- return { geometry }
370
+ return { geometry, sourceCoordinateFrame: false }
242
371
  }
243
372
 
244
373
  const center =
@@ -255,6 +384,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
255
384
 
256
385
  return {
257
386
  placementCenterMil: placementCenter,
387
+ sourceCoordinateFrame: true,
258
388
  geometry: {
259
389
  ...geometry,
260
390
  verticesMil: geometry.verticesMil.map((vertex) => {
@@ -0,0 +1,445 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Recovers missing anonymous static-body polygons from complete same-model rows.
7
+ */
8
+ export class PcbScene3dStaticBodyPrototypeRecovery {
9
+ /**
10
+ * Recovers repeated anonymous body rows that omit their vertex payload.
11
+ * @param {object[] | undefined} componentBodies Component bodies.
12
+ * @returns {object[]}
13
+ */
14
+ static recover(componentBodies) {
15
+ const bodies = Array.isArray(componentBodies) ? componentBodies : []
16
+ const prototypes =
17
+ PcbScene3dStaticBodyPrototypeRecovery.#prototypeMap(bodies)
18
+
19
+ if (!prototypes.size) {
20
+ return bodies
21
+ }
22
+
23
+ return bodies.map((componentBody) =>
24
+ PcbScene3dStaticBodyPrototypeRecovery.#recoverBody(
25
+ componentBody,
26
+ prototypes
27
+ )
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Builds reusable complete-geometry prototypes by exact model identity.
33
+ * @param {object[]} componentBodies Component bodies.
34
+ * @returns {Map<string, object[]>}
35
+ */
36
+ static #prototypeMap(componentBodies) {
37
+ const prototypes = new Map()
38
+
39
+ componentBodies
40
+ .filter((componentBody) =>
41
+ PcbScene3dStaticBodyPrototypeRecovery.#isPrototype(
42
+ componentBody
43
+ )
44
+ )
45
+ .forEach((componentBody) => {
46
+ const key =
47
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(
48
+ componentBody
49
+ )
50
+ if (!key) {
51
+ return
52
+ }
53
+
54
+ if (!prototypes.has(key)) {
55
+ prototypes.set(key, [])
56
+ }
57
+ prototypes.get(key)?.push(componentBody)
58
+ })
59
+
60
+ return prototypes
61
+ }
62
+
63
+ /**
64
+ * Recovers one missing body from the closest compatible prototype.
65
+ * @param {object} componentBody Target body.
66
+ * @param {Map<string, object[]>} prototypes Complete prototype rows.
67
+ * @returns {object}
68
+ */
69
+ static #recoverBody(componentBody, prototypes) {
70
+ if (
71
+ !PcbScene3dStaticBodyPrototypeRecovery.#isRecoverableTarget(
72
+ componentBody
73
+ )
74
+ ) {
75
+ return componentBody
76
+ }
77
+
78
+ const key =
79
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(componentBody)
80
+ const prototype =
81
+ PcbScene3dStaticBodyPrototypeRecovery.#nearestPrototype(
82
+ componentBody,
83
+ prototypes.get(key)
84
+ )
85
+
86
+ return prototype
87
+ ? PcbScene3dStaticBodyPrototypeRecovery.#withPrototypeGeometry(
88
+ componentBody,
89
+ prototype
90
+ )
91
+ : componentBody
92
+ }
93
+
94
+ /**
95
+ * Checks whether a row can provide reusable static geometry.
96
+ * @param {object | undefined} componentBody Candidate body.
97
+ * @returns {boolean}
98
+ */
99
+ static #isPrototype(componentBody) {
100
+ return (
101
+ PcbScene3dStaticBodyPrototypeRecovery.#isAnonymous(componentBody) &&
102
+ PcbScene3dStaticBodyPrototypeRecovery.#isCompleteExtrudedPolygon(
103
+ componentBody
104
+ ) &&
105
+ Boolean(
106
+ PcbScene3dStaticBodyPrototypeRecovery.#bodyFamilyKey(
107
+ componentBody
108
+ )
109
+ )
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Checks whether a row is missing renderable static polygon vertices.
115
+ * @param {object | undefined} componentBody Candidate body.
116
+ * @returns {boolean}
117
+ */
118
+ static #isRecoverableTarget(componentBody) {
119
+ return (
120
+ PcbScene3dStaticBodyPrototypeRecovery.#isAnonymous(componentBody) &&
121
+ PcbScene3dStaticBodyPrototypeRecovery.#isExtrudedPolygonBody(
122
+ componentBody
123
+ ) &&
124
+ !PcbScene3dStaticBodyPrototypeRecovery.#isCompleteExtrudedPolygon(
125
+ componentBody
126
+ ) &&
127
+ PcbScene3dStaticBodyPrototypeRecovery.#hasRecoverablePosition(
128
+ componentBody
129
+ ) &&
130
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody) > 0
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Checks whether the body row has no authored identity text.
136
+ * @param {object | undefined} componentBody Candidate body.
137
+ * @returns {boolean}
138
+ */
139
+ static #isAnonymous(componentBody) {
140
+ return ![componentBody?.identifier, componentBody?.name]
141
+ .map((value) => String(value || '').trim())
142
+ .some(Boolean)
143
+ }
144
+
145
+ /**
146
+ * Checks whether a body row describes an extruded polygon model.
147
+ * @param {object | undefined} componentBody Candidate body.
148
+ * @returns {boolean}
149
+ */
150
+ static #isExtrudedPolygonBody(componentBody) {
151
+ return (
152
+ PcbScene3dStaticBodyPrototypeRecovery.#geometryKind(
153
+ componentBody
154
+ ) === 'extruded-polygon'
155
+ )
156
+ }
157
+
158
+ /**
159
+ * Checks whether a row already has complete extruded polygon vertices.
160
+ * @param {object | undefined} componentBody Candidate body.
161
+ * @returns {boolean}
162
+ */
163
+ static #isCompleteExtrudedPolygon(componentBody) {
164
+ const geometry = componentBody?.staticGeometry
165
+
166
+ return (
167
+ PcbScene3dStaticBodyPrototypeRecovery.#isExtrudedPolygonBody(
168
+ componentBody
169
+ ) &&
170
+ String(geometry?.status || '').toLowerCase() === 'complete' &&
171
+ Array.isArray(geometry?.verticesMil) &&
172
+ geometry.verticesMil.length >= 3
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Builds the exact model-family key used for prototype reuse.
178
+ * @param {object | undefined} componentBody Candidate body.
179
+ * @returns {string}
180
+ */
181
+ static #bodyFamilyKey(componentBody) {
182
+ const identity = [
183
+ componentBody?.modelId,
184
+ componentBody?.checksum,
185
+ PcbScene3dStaticBodyPrototypeRecovery.#geometryKind(componentBody),
186
+ PcbScene3dStaticBodyPrototypeRecovery.#numberKey(
187
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody)
188
+ ),
189
+ PcbScene3dStaticBodyPrototypeRecovery.#numberKey(
190
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(
191
+ componentBody
192
+ )
193
+ )
194
+ ]
195
+ .map((value) =>
196
+ String(value ?? '')
197
+ .trim()
198
+ .toLowerCase()
199
+ )
200
+ .filter(Boolean)
201
+
202
+ return identity.length >= 4 ? identity.join('|') : ''
203
+ }
204
+
205
+ /**
206
+ * Chooses the closest layer-compatible prototype for one target row.
207
+ * @param {object} componentBody Target body.
208
+ * @param {object[] | undefined} candidates Candidate prototypes.
209
+ * @returns {object | null}
210
+ */
211
+ static #nearestPrototype(componentBody, candidates) {
212
+ const normalizedCandidates = Array.isArray(candidates) ? candidates : []
213
+
214
+ return (
215
+ normalizedCandidates
216
+ .filter((candidate) =>
217
+ PcbScene3dStaticBodyPrototypeRecovery.#compatibleLayers(
218
+ componentBody,
219
+ candidate
220
+ )
221
+ )
222
+ .map((candidate) => ({
223
+ candidate,
224
+ distance:
225
+ PcbScene3dStaticBodyPrototypeRecovery.#distanceBetweenBodies(
226
+ componentBody,
227
+ candidate
228
+ )
229
+ }))
230
+ .sort((left, right) => left.distance - right.distance)[0]
231
+ ?.candidate || null
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Returns a cloned target row with translated prototype vertices.
237
+ * @param {object} componentBody Target body.
238
+ * @param {object} prototype Complete prototype body.
239
+ * @returns {object}
240
+ */
241
+ static #withPrototypeGeometry(componentBody, prototype) {
242
+ const geometry = componentBody?.staticGeometry || {}
243
+ const prototypeGeometry = prototype?.staticGeometry || {}
244
+ const heightMil =
245
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(componentBody) ||
246
+ PcbScene3dStaticBodyPrototypeRecovery.#heightMil(prototype)
247
+ const standoffHeightMil =
248
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(componentBody) ||
249
+ PcbScene3dStaticBodyPrototypeRecovery.#standoffMil(prototype)
250
+
251
+ return {
252
+ ...componentBody,
253
+ staticGeometry: {
254
+ ...prototypeGeometry,
255
+ ...geometry,
256
+ kind: 'extruded-polygon',
257
+ status: 'complete',
258
+ units: geometry.units || prototypeGeometry.units || 'mil',
259
+ minZMil: Number(
260
+ geometry.minZMil ?? prototypeGeometry.minZMil ?? 0
261
+ ),
262
+ maxZMil: Number(
263
+ geometry.maxZMil ??
264
+ prototypeGeometry.maxZMil ??
265
+ heightMil + standoffHeightMil
266
+ ),
267
+ heightMil,
268
+ standoffHeightMil,
269
+ verticesMil:
270
+ PcbScene3dStaticBodyPrototypeRecovery.#prototypeVerticesForTarget(
271
+ componentBody,
272
+ prototype
273
+ )
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Translates prototype vertices into the target body's coordinate frame.
280
+ * @param {object} componentBody Target body.
281
+ * @param {object} prototype Complete prototype body.
282
+ * @returns {{ x: number, y: number }[]}
283
+ */
284
+ static #prototypeVerticesForTarget(componentBody, prototype) {
285
+ const vertices = Array.isArray(prototype?.staticGeometry?.verticesMil)
286
+ ? prototype.staticGeometry.verticesMil
287
+ : []
288
+
289
+ if (
290
+ !PcbScene3dStaticBodyPrototypeRecovery.#usesSourceCoordinateFrame(
291
+ prototype,
292
+ vertices
293
+ )
294
+ ) {
295
+ return vertices.map((vertex) => ({
296
+ x: Number(vertex?.x || 0),
297
+ y: Number(vertex?.y || 0)
298
+ }))
299
+ }
300
+
301
+ const prototypeSource =
302
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(prototype)
303
+ const targetSource =
304
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
305
+
306
+ return vertices.map((vertex) => ({
307
+ x: targetSource.x + Number(vertex?.x || 0) - prototypeSource.x,
308
+ y: targetSource.y + Number(vertex?.y || 0) - prototypeSource.y
309
+ }))
310
+ }
311
+
312
+ /**
313
+ * Checks whether explicit mechanical layer values are compatible.
314
+ * @param {object | undefined} left Left body.
315
+ * @param {object | undefined} right Right body.
316
+ * @returns {boolean}
317
+ */
318
+ static #compatibleLayers(left, right) {
319
+ const leftLayer = String(left?.layer || '')
320
+ .trim()
321
+ .toLowerCase()
322
+ const rightLayer = String(right?.layer || '')
323
+ .trim()
324
+ .toLowerCase()
325
+
326
+ return !leftLayer || !rightLayer || leftLayer === rightLayer
327
+ }
328
+
329
+ /**
330
+ * Resolves the lower-case geometry kind for a body row.
331
+ * @param {object | undefined} componentBody Candidate body.
332
+ * @returns {string}
333
+ */
334
+ static #geometryKind(componentBody) {
335
+ return String(
336
+ componentBody?.staticGeometry?.kind ||
337
+ componentBody?.modelTypeName ||
338
+ ''
339
+ )
340
+ .trim()
341
+ .toLowerCase()
342
+ }
343
+
344
+ /**
345
+ * Resolves the body extrusion height.
346
+ * @param {object | undefined} componentBody Candidate body.
347
+ * @returns {number}
348
+ */
349
+ static #heightMil(componentBody) {
350
+ const height = Number(
351
+ componentBody?.staticGeometry?.heightMil ??
352
+ componentBody?.overallHeightMil
353
+ )
354
+
355
+ return Number.isFinite(height) ? height : 0
356
+ }
357
+
358
+ /**
359
+ * Resolves the body standoff height.
360
+ * @param {object | undefined} componentBody Candidate body.
361
+ * @returns {number}
362
+ */
363
+ static #standoffMil(componentBody) {
364
+ const standoff = Number(
365
+ componentBody?.staticGeometry?.standoffHeightMil ??
366
+ componentBody?.standoffHeightMil
367
+ )
368
+
369
+ return Number.isFinite(standoff) ? standoff : 0
370
+ }
371
+
372
+ /**
373
+ * Checks whether the body row has a finite source anchor.
374
+ * @param {object | undefined} componentBody Candidate body.
375
+ * @returns {boolean}
376
+ */
377
+ static #hasRecoverablePosition(componentBody) {
378
+ const source =
379
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
380
+
381
+ return Number.isFinite(source.x) && Number.isFinite(source.y)
382
+ }
383
+
384
+ /**
385
+ * Resolves a body source position.
386
+ * @param {object | undefined} componentBody Candidate body.
387
+ * @returns {{ x: number, y: number }}
388
+ */
389
+ static #sourcePosition(componentBody) {
390
+ return {
391
+ x: Number(componentBody?.positionMil?.x),
392
+ y: Number(componentBody?.positionMil?.y)
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Measures the source-anchor distance between two body rows.
398
+ * @param {object} left Left body.
399
+ * @param {object} right Right body.
400
+ * @returns {number}
401
+ */
402
+ static #distanceBetweenBodies(left, right) {
403
+ const leftSource =
404
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(left)
405
+ const rightSource =
406
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(right)
407
+
408
+ return Math.hypot(
409
+ leftSource.x - rightSource.x,
410
+ leftSource.y - rightSource.y
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Checks whether polygon vertices use board/source coordinates.
416
+ * @param {object} componentBody Component body.
417
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
418
+ * @returns {boolean}
419
+ */
420
+ static #usesSourceCoordinateFrame(componentBody, vertices) {
421
+ const source =
422
+ PcbScene3dStaticBodyPrototypeRecovery.#sourcePosition(componentBody)
423
+ const coordinates = (Array.isArray(vertices) ? vertices : []).flatMap(
424
+ (vertex) => [Number(vertex?.x || 0), Number(vertex?.y || 0)]
425
+ )
426
+
427
+ return (
428
+ Math.max(Math.abs(source.x), Math.abs(source.y), ...coordinates) >
429
+ 1000
430
+ )
431
+ }
432
+
433
+ /**
434
+ * Converts a number into a stable key fragment.
435
+ * @param {number | string | undefined | null} value Candidate value.
436
+ * @returns {string}
437
+ */
438
+ static #numberKey(value) {
439
+ const number = Number(value)
440
+
441
+ return Number.isFinite(number)
442
+ ? String(Math.round(number * 10000) / 10000)
443
+ : ''
444
+ }
445
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbScene3dStaticBodySymmetryRecovery } from './PcbScene3dStaticBodySymmetryRecovery.mjs'
6
+ import { PcbScene3dStaticBodyPrototypeRecovery } from './PcbScene3dStaticBodyPrototypeRecovery.mjs'
6
7
 
7
8
  /**
8
9
  * Recovers renderable static-body geometry and display metadata.
@@ -38,8 +39,13 @@ export class PcbScene3dStaticBodyRecovery {
38
39
  */
39
40
  static recover(componentBodies, bodyMatches = []) {
40
41
  const bodies = Array.isArray(componentBodies) ? componentBodies : []
42
+ const prototypeRecoveredBodies =
43
+ PcbScene3dStaticBodyPrototypeRecovery.recover(bodies)
41
44
  const symmetricRecoveredBodies =
42
- PcbScene3dStaticBodySymmetryRecovery.recover(bodies, bodyMatches)
45
+ PcbScene3dStaticBodySymmetryRecovery.recover(
46
+ prototypeRecoveredBodies,
47
+ bodyMatches
48
+ )
43
49
  const topRecoveredBodies = symmetricRecoveredBodies.map(
44
50
  (componentBody) =>
45
51
  PcbScene3dStaticBodyRecovery.#recoverCoverTopGeometry(