altium-toolkit 1.1.33 → 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.
Files changed (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/docs/model-format.md +2 -3
  3. package/package.json +1 -1
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
  6. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  7. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
  8. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  9. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  11. package/src/core/altium/SchematicTextParser.mjs +18 -253
  12. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  13. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  14. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  15. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  16. package/src/parser.mjs +1 -0
  17. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  18. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
  19. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  20. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  21. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +225 -17
  22. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  23. package/src/ui/PcbScene3dBuilder.mjs +470 -20
  24. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  25. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  26. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  27. package/src/ui/PcbScene3dPackages.mjs +31 -4
  28. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  29. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
  30. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +471 -35
  31. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  32. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +897 -0
  33. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +555 -0
  34. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
  35. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +897 -0
  36. package/src/ui/SchematicColorResolver.mjs +44 -0
  37. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  38. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -3,6 +3,9 @@
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'
7
+ import { PcbScene3dStaticBodyRecovery } from './PcbScene3dStaticBodyRecovery.mjs'
8
+ import { PcbScene3dStaticBodySelectionKeyBuilder } from './PcbScene3dStaticBodySelectionKeyBuilder.mjs'
6
9
 
7
10
  /**
8
11
  * Builds scene placements for static shape-based 3D bodies.
@@ -12,10 +15,14 @@ export class PcbScene3dStaticBodyPlacementBuilder {
12
15
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
13
16
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
14
17
  static #OWNER_AFFINITY_DISTANCE_MIL = 600
18
+ static #OWNER_BOUNDS_TOLERANCE_MIL = 20
15
19
  static #OWNER_EXACT_DISTANCE_MIL = 5
16
20
  static #OWNER_EXACT_MAX_SPAN_MIL = 500
21
+ static #SOURCE_COORDINATE_MIRROR_TOLERANCE_MIL = 5
22
+ static #SOURCE_COORDINATE_MIRROR_MIN_OFFSET_MIL = 20
17
23
  static #GENERIC_MECHANICAL_IDENTITY_TOKENS = new Set([
18
24
  'can',
25
+ 'clip',
19
26
  'cover',
20
27
  'enclosure',
21
28
  'frame',
@@ -34,7 +41,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
34
41
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads Pads.
35
42
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
36
43
  * @param {number} thicknessMil Board thickness.
37
- * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
44
+ * @returns {{ designator: string, selectionKey: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
38
45
  */
39
46
  static build(
40
47
  componentBodies,
@@ -44,9 +51,14 @@ export class PcbScene3dStaticBodyPlacementBuilder {
44
51
  board,
45
52
  thicknessMil
46
53
  ) {
47
- return (Array.isArray(componentBodies) ? componentBodies : [])
54
+ const recoveredBodies = PcbScene3dStaticBodyRecovery.recover(
55
+ componentBodies,
56
+ bodyMatches
57
+ )
58
+
59
+ const placementRows = recoveredBodies
48
60
  .map((componentBody, index) =>
49
- PcbScene3dStaticBodyPlacementBuilder.#buildPlacement(
61
+ PcbScene3dStaticBodyPlacementBuilder.#buildPlacementRow(
50
62
  componentBody,
51
63
  bodyMatches?.[index] || null,
52
64
  components,
@@ -55,6 +67,14 @@ export class PcbScene3dStaticBodyPlacementBuilder {
55
67
  )
56
68
  )
57
69
  .filter(Boolean)
70
+
71
+ PcbScene3dStaticBodyOwnerPromotion.promote(
72
+ placementRows,
73
+ components,
74
+ board
75
+ )
76
+
77
+ return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
58
78
  }
59
79
 
60
80
  /**
@@ -66,6 +86,38 @@ export class PcbScene3dStaticBodyPlacementBuilder {
66
86
  return Boolean(geometry && geometry.status === 'complete')
67
87
  }
68
88
 
89
+ /**
90
+ * Builds one static body placement row with its matched owner context.
91
+ * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, bodyOpacity?: number | string, staticGeometry?: object }} componentBody Component body.
92
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent Matched component.
93
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
94
+ * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
95
+ * @param {number} thicknessMil Board thickness.
96
+ * @returns {{ placement: { designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }, matchedComponent: object | null } | null}
97
+ */
98
+ static #buildPlacementRow(
99
+ componentBody,
100
+ matchedComponent,
101
+ components,
102
+ board,
103
+ thicknessMil
104
+ ) {
105
+ const placement = PcbScene3dStaticBodyPlacementBuilder.#buildPlacement(
106
+ componentBody,
107
+ matchedComponent,
108
+ components,
109
+ board,
110
+ thicknessMil
111
+ )
112
+
113
+ return placement
114
+ ? {
115
+ placement,
116
+ matchedComponent
117
+ }
118
+ : null
119
+ }
120
+
69
121
  /**
70
122
  * Builds one static shape-body scene placement.
71
123
  * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, bodyOpacity?: number | string, staticGeometry?: object }} componentBody Component body.
@@ -73,7 +125,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
73
125
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
74
126
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
75
127
  * @param {number} thicknessMil Board thickness.
76
- * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object } | null}
128
+ * @returns {{ designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object } | null}
77
129
  */
78
130
  static #buildPlacement(
79
131
  componentBody,
@@ -82,24 +134,30 @@ export class PcbScene3dStaticBodyPlacementBuilder {
82
134
  board,
83
135
  thicknessMil
84
136
  ) {
85
- const geometry =
137
+ const staticGeometry =
86
138
  PcbScene3dStaticBodyPlacementBuilder.#normalizeStaticGeometry(
87
139
  componentBody,
88
- componentBody?.staticGeometry
140
+ componentBody?.staticGeometry,
141
+ matchedComponent
89
142
  )
143
+ const geometry = staticGeometry.geometry
90
144
 
91
145
  if (
92
146
  !PcbScene3dStaticBodyPlacementBuilder.#isCompleteGeometry(geometry)
93
147
  ) {
94
148
  return null
95
149
  }
150
+ const sourcePosition =
151
+ staticGeometry.placementCenterMil ||
152
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
96
153
 
97
154
  if (
98
155
  !matchedComponent &&
99
156
  PcbScene3dStaticBodyPlacementBuilder.#shouldSuppressStaticBody(
100
157
  componentBody,
101
158
  geometry,
102
- components
159
+ components,
160
+ sourcePosition
103
161
  )
104
162
  ) {
105
163
  return null
@@ -122,8 +180,6 @@ export class PcbScene3dStaticBodyPlacementBuilder {
122
180
  components,
123
181
  board
124
182
  )
125
- const sourcePosition =
126
- PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
127
183
  const heightMil =
128
184
  PcbScene3dStaticBodyPlacementBuilder.#geometryHeight(geometry)
129
185
  const standoffMil = Math.abs(
@@ -135,6 +191,12 @@ export class PcbScene3dStaticBodyPlacementBuilder {
135
191
  Number(thicknessMil || 0) / 2 +
136
192
  (Number.isFinite(standoffMil) ? standoffMil : 0) +
137
193
  heightMil / 2
194
+ const renderGeometry =
195
+ PcbScene3dStaticBodyPlacementBuilder.#placementGeometry(
196
+ geometry,
197
+ staticGeometry,
198
+ mountSide
199
+ )
138
200
 
139
201
  return {
140
202
  designator:
@@ -142,11 +204,25 @@ export class PcbScene3dStaticBodyPlacementBuilder {
142
204
  String(
143
205
  componentBody.identifier || componentBody.name || '3D body'
144
206
  ),
145
- mountSide,
146
- rotationDeg: PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
147
- Number(componentBody.rotationDeg || 0) +
148
- Number(matchedComponent?.rotation || 0)
207
+ sourceIdentityKey:
208
+ PcbScene3dStaticBodyPlacementBuilder.#sourceIdentityKey(
209
+ componentBody
210
+ ),
211
+ sourceCoordinateFrame: Boolean(
212
+ staticGeometry.sourceCoordinateFrame
149
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
+ ),
150
226
  positionMil: {
151
227
  x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
152
228
  Number(sourcePosition.x || 0) - Number(board.centerX || 0)
@@ -165,7 +241,107 @@ export class PcbScene3dStaticBodyPlacementBuilder {
165
241
  ...PcbScene3dStaticBodyPlacementBuilder.#displayMetadata(
166
242
  componentBody
167
243
  ),
168
- 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
+ }))
169
345
  }
170
346
  }
171
347
 
@@ -173,36 +349,161 @@ export class PcbScene3dStaticBodyPlacementBuilder {
173
349
  * Converts source-space polygon vertices into render-local geometry.
174
350
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
175
351
  * @param {object | undefined} geometry Static geometry.
176
- * @returns {object | undefined}
352
+ * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
353
+ * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number }, sourceCoordinateFrame: boolean }}
177
354
  */
178
- static #normalizeStaticGeometry(componentBody, geometry) {
355
+ static #normalizeStaticGeometry(componentBody, geometry, matchedComponent) {
179
356
  if (
180
357
  String(geometry?.kind || '').toLowerCase() !== 'extruded-polygon' ||
181
358
  !Array.isArray(geometry?.verticesMil) ||
182
- geometry.verticesMil.length < 3 ||
359
+ geometry.verticesMil.length < 3
360
+ ) {
361
+ return { geometry, sourceCoordinateFrame: false }
362
+ }
363
+
364
+ if (
183
365
  !PcbScene3dStaticBodyPlacementBuilder.#usesSourceCoordinateFrame(
184
366
  componentBody,
185
367
  geometry.verticesMil
186
368
  )
187
369
  ) {
188
- return geometry
370
+ return { geometry, sourceCoordinateFrame: false }
189
371
  }
190
372
 
191
373
  const center =
192
374
  PcbScene3dStaticBodyPlacementBuilder.#polygonBoundsCenter(
193
375
  geometry.verticesMil
194
376
  )
377
+ const mirror =
378
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirror(
379
+ componentBody,
380
+ center,
381
+ matchedComponent
382
+ )
383
+ const placementCenter = mirror?.placementCenterMil || center
195
384
 
196
385
  return {
197
- ...geometry,
198
- verticesMil: geometry.verticesMil.map((vertex) => ({
199
- x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
200
- Number(vertex?.x || 0) - center.x
201
- ),
202
- y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
203
- Number(vertex?.y || 0) - center.y
204
- )
205
- }))
386
+ placementCenterMil: placementCenter,
387
+ sourceCoordinateFrame: true,
388
+ geometry: {
389
+ ...geometry,
390
+ verticesMil: geometry.verticesMil.map((vertex) => {
391
+ const normalizedVertex =
392
+ PcbScene3dStaticBodyPlacementBuilder.#normalizeVertex(
393
+ vertex,
394
+ mirror
395
+ )
396
+
397
+ return {
398
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
399
+ normalizedVertex.x - placementCenter.x
400
+ ),
401
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
402
+ normalizedVertex.y - placementCenter.y
403
+ )
404
+ }
405
+ })
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Resolves owner-axis mirror transforms for source-coordinate polygons
412
+ * whose body anchor is opposite their raw polygon bounds.
413
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
414
+ * @param {{ x: number, y: number }} boundsCenter Source-coordinate polygon bounds center.
415
+ * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
416
+ * @returns {{ mirrorAxisX?: number, mirrorAxisY?: number, placementCenterMil: { x: number, y: number } } | null}
417
+ */
418
+ static #sourceCoordinateMirror(
419
+ componentBody,
420
+ boundsCenter,
421
+ matchedComponent
422
+ ) {
423
+ const source =
424
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
425
+ const xDecision =
426
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirrorAxis(
427
+ boundsCenter.x,
428
+ source.x,
429
+ Number(matchedComponent?.x)
430
+ )
431
+ const yDecision =
432
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirrorAxis(
433
+ boundsCenter.y,
434
+ source.y,
435
+ Number(matchedComponent?.y)
436
+ )
437
+
438
+ if (
439
+ !xDecision.valid ||
440
+ !yDecision.valid ||
441
+ (!xDecision.mirrored && !yDecision.mirrored)
442
+ ) {
443
+ return null
444
+ }
445
+
446
+ return {
447
+ mirrorAxisX: xDecision.mirrored
448
+ ? Number(matchedComponent?.x)
449
+ : undefined,
450
+ mirrorAxisY: yDecision.mirrored
451
+ ? Number(matchedComponent?.y)
452
+ : undefined,
453
+ placementCenterMil: {
454
+ x: source.x,
455
+ y: source.y
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Checks whether one source-coordinate axis is aligned or mirrored.
462
+ * @param {number} center Raw polygon center coordinate.
463
+ * @param {number} source Body source coordinate.
464
+ * @param {number} ownerAxis Owner coordinate.
465
+ * @returns {{ valid: boolean, mirrored: boolean }}
466
+ */
467
+ static #sourceCoordinateMirrorAxis(center, source, ownerAxis) {
468
+ const tolerance =
469
+ PcbScene3dStaticBodyPlacementBuilder
470
+ .#SOURCE_COORDINATE_MIRROR_TOLERANCE_MIL
471
+ const offset = Math.abs(Number(center || 0) - Number(source || 0))
472
+ if (offset <= tolerance) {
473
+ return { valid: true, mirrored: false }
474
+ }
475
+
476
+ const mirroredCenter = 2 * Number(ownerAxis) - Number(center || 0)
477
+ const mirrorError = Math.abs(mirroredCenter - Number(source || 0))
478
+
479
+ return {
480
+ valid:
481
+ Number.isFinite(ownerAxis) &&
482
+ offset >
483
+ PcbScene3dStaticBodyPlacementBuilder
484
+ .#SOURCE_COORDINATE_MIRROR_MIN_OFFSET_MIL &&
485
+ mirrorError <= tolerance,
486
+ mirrored: true
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Converts one source-space vertex into render source-space coordinates.
492
+ * @param {{ x?: number, y?: number }} vertex Source vertex.
493
+ * @param {{ mirrorAxisX?: number, mirrorAxisY?: number } | null} mirror Optional mirror transform.
494
+ * @returns {{ x: number, y: number }}
495
+ */
496
+ static #normalizeVertex(vertex, mirror) {
497
+ const sourceX = Number(vertex?.x || 0)
498
+ const sourceY = Number(vertex?.y || 0)
499
+
500
+ return {
501
+ x: Number.isFinite(mirror?.mirrorAxisX)
502
+ ? 2 * mirror.mirrorAxisX - sourceX
503
+ : sourceX,
504
+ y: Number.isFinite(mirror?.mirrorAxisY)
505
+ ? 2 * mirror.mirrorAxisY - sourceY
506
+ : sourceY
206
507
  }
207
508
  }
208
509
 
@@ -264,23 +565,45 @@ export class PcbScene3dStaticBodyPlacementBuilder {
264
565
  metadata.bodyColor = componentBody.bodyColor
265
566
  }
266
567
 
267
- const opacity = Number(componentBody?.bodyOpacity)
268
- if (Number.isFinite(opacity)) {
568
+ const opacity = PcbScene3dStaticBodyRecovery.renderableOpacity(
569
+ componentBody?.bodyOpacity
570
+ )
571
+ if (opacity !== undefined) {
269
572
  metadata.bodyOpacity = opacity
270
573
  }
271
574
 
272
575
  return metadata
273
576
  }
274
577
 
578
+ /**
579
+ * Resolves the stable source identity shared by sibling static bodies.
580
+ * @param {{ identifier?: string, name?: string, modelId?: string }} componentBody Component body.
581
+ * @returns {string}
582
+ */
583
+ static #sourceIdentityKey(componentBody) {
584
+ return String(
585
+ componentBody?.identifier ||
586
+ componentBody?.name ||
587
+ componentBody?.modelId ||
588
+ ''
589
+ ).trim()
590
+ }
591
+
275
592
  /**
276
593
  * Checks whether an unowned static body is likely a translucent mechanical
277
594
  * overlay instead of a component package.
278
595
  * @param {{ componentIndex?: number, embedded?: boolean, bodyOpacity?: number | string, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
279
596
  * @param {object} geometry Static geometry.
280
597
  * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
598
+ * @param {{ x: number, y: number }} sourcePosition Source-space placement center.
281
599
  * @returns {boolean}
282
600
  */
283
- static #shouldSuppressStaticBody(componentBody, geometry, components) {
601
+ static #shouldSuppressStaticBody(
602
+ componentBody,
603
+ geometry,
604
+ components,
605
+ sourcePosition
606
+ ) {
284
607
  return (
285
608
  PcbScene3dStaticBodyPlacementBuilder.#isTranslucentBody(
286
609
  componentBody
@@ -288,7 +611,8 @@ export class PcbScene3dStaticBodyPlacementBuilder {
288
611
  !PcbScene3dStaticBodyPlacementBuilder.#hasLikelyComponentOwner(
289
612
  componentBody,
290
613
  geometry,
291
- components
614
+ components,
615
+ sourcePosition
292
616
  )
293
617
  )
294
618
  }
@@ -299,9 +623,11 @@ export class PcbScene3dStaticBodyPlacementBuilder {
299
623
  * @returns {boolean}
300
624
  */
301
625
  static #isTranslucentBody(componentBody) {
302
- const opacity = Number(componentBody?.bodyOpacity)
303
-
304
- return Number.isFinite(opacity) && opacity >= 0 && opacity < 1
626
+ return (
627
+ PcbScene3dStaticBodyRecovery.renderableOpacity(
628
+ componentBody?.bodyOpacity
629
+ ) !== undefined
630
+ )
305
631
  }
306
632
 
307
633
  /**
@@ -310,9 +636,15 @@ export class PcbScene3dStaticBodyPlacementBuilder {
310
636
  * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
311
637
  * @param {object} geometry Static geometry.
312
638
  * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
639
+ * @param {{ x: number, y: number }} sourcePosition Source-space placement center.
313
640
  * @returns {boolean}
314
641
  */
315
- static #hasLikelyComponentOwner(componentBody, geometry, components) {
642
+ static #hasLikelyComponentOwner(
643
+ componentBody,
644
+ geometry,
645
+ components,
646
+ sourcePosition
647
+ ) {
316
648
  if (componentBody?.embedded) {
317
649
  return true
318
650
  }
@@ -334,6 +666,11 @@ export class PcbScene3dStaticBodyPlacementBuilder {
334
666
  PcbScene3dStaticBodyPlacementBuilder.#hasGenericMechanicalIdentity(
335
667
  componentBody
336
668
  )
669
+ const sourceBounds =
670
+ PcbScene3dStaticBodyPlacementBuilder.#geometrySourceBounds(
671
+ geometry,
672
+ sourcePosition
673
+ )
337
674
 
338
675
  return (Array.isArray(components) ? components : []).some(
339
676
  (component) => {
@@ -349,6 +686,19 @@ export class PcbScene3dStaticBodyPlacementBuilder {
349
686
  )
350
687
 
351
688
  return (
689
+ (genericMechanical &&
690
+ PcbScene3dStaticBodyPlacementBuilder.#isShieldOwner(
691
+ component
692
+ ) &&
693
+ (distance <=
694
+ PcbScene3dStaticBodyPlacementBuilder
695
+ .#OWNER_AFFINITY_DISTANCE_MIL ||
696
+ PcbScene3dStaticBodyPlacementBuilder.#boundsContainPoint(
697
+ sourceBounds,
698
+ component,
699
+ PcbScene3dStaticBodyPlacementBuilder
700
+ .#OWNER_BOUNDS_TOLERANCE_MIL
701
+ ))) ||
352
702
  (!genericMechanical &&
353
703
  affinityScore > 0 &&
354
704
  distance <=
@@ -366,6 +716,91 @@ export class PcbScene3dStaticBodyPlacementBuilder {
366
716
  )
367
717
  }
368
718
 
719
+ /**
720
+ * Resolves source-space bounds for one static polygon geometry.
721
+ * @param {object} geometry Static geometry.
722
+ * @param {{ x?: number, y?: number }} sourcePosition Source-space placement center.
723
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
724
+ */
725
+ static #geometrySourceBounds(geometry, sourcePosition) {
726
+ if (
727
+ !Array.isArray(geometry?.verticesMil) ||
728
+ !geometry.verticesMil.length
729
+ ) {
730
+ return null
731
+ }
732
+
733
+ const points = geometry.verticesMil
734
+ .map((vertex) => ({
735
+ x: Number(vertex?.x || 0) + Number(sourcePosition?.x || 0),
736
+ y: Number(vertex?.y || 0) + Number(sourcePosition?.y || 0)
737
+ }))
738
+ .filter(
739
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
740
+ )
741
+ if (!points.length) {
742
+ return null
743
+ }
744
+
745
+ const xs = points.map((point) => point.x)
746
+ const ys = points.map((point) => point.y)
747
+
748
+ return {
749
+ minX: Math.min(...xs),
750
+ minY: Math.min(...ys),
751
+ maxX: Math.max(...xs),
752
+ maxY: Math.max(...ys)
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Checks whether bounds contain a component point with tolerance.
758
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
759
+ * @param {{ x?: number, y?: number }} point Candidate point.
760
+ * @param {number} toleranceMil Bounds tolerance.
761
+ * @returns {boolean}
762
+ */
763
+ static #boundsContainPoint(bounds, point, toleranceMil) {
764
+ if (!bounds) {
765
+ return false
766
+ }
767
+
768
+ const x = Number(point?.x || 0)
769
+ const y = Number(point?.y || 0)
770
+ const tolerance = Math.max(Number(toleranceMil || 0), 0)
771
+
772
+ return (
773
+ Number.isFinite(x) &&
774
+ Number.isFinite(y) &&
775
+ x >= bounds.minX - tolerance &&
776
+ x <= bounds.maxX + tolerance &&
777
+ y >= bounds.minY - tolerance &&
778
+ y <= bounds.maxY + tolerance
779
+ )
780
+ }
781
+
782
+ /**
783
+ * Checks whether a component identity describes a shield owner strongly
784
+ * enough to keep generic translucent shield sub-bodies.
785
+ * @param {{ designator?: string, pattern?: string, source?: string, description?: string, parameters?: object, provenance?: object }} component Component row.
786
+ * @returns {boolean}
787
+ */
788
+ static #isShieldOwner(component) {
789
+ const identityText = [
790
+ component?.pattern,
791
+ component?.source,
792
+ component?.description,
793
+ ...Object.values(component?.parameters || {}),
794
+ ...Object.values(component?.provenance || {})
795
+ ]
796
+ .join(' ')
797
+ .toLowerCase()
798
+
799
+ return /(?:^|[^a-z0-9])(?:emi|rfi|shield)(?:$|[^a-z0-9])/u.test(
800
+ identityText
801
+ )
802
+ }
803
+
369
804
  /**
370
805
  * Checks for generic mechanical labels that are too weak to prove package
371
806
  * ownership for translucent static bodies.
@@ -390,6 +825,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
390
825
  static #identityTokens(componentBody) {
391
826
  return [componentBody?.identifier, componentBody?.name]
392
827
  .join(' ')
828
+ .replace(/([a-z])([A-Z])/gu, '$1 $2')
393
829
  .toLowerCase()
394
830
  .split(/[^a-z0-9]+/g)
395
831
  .flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])