altium-toolkit 1.1.36 → 1.1.37

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 { PcbScene3dStaticBodySymmetricOwnerPromotion } from './PcbScene3dStaticBodySymmetricOwnerPromotion.mjs'
6
+ import { PcbScene3dStaticBodyPadOwnerPromotion } from './PcbScene3dStaticBodyPadOwnerPromotion.mjs'
6
7
 
7
8
  /**
8
9
  * Promotes compact static body fragments to one clear touching owner.
@@ -11,7 +12,20 @@ export class PcbScene3dStaticBodyOwnerPromotion {
11
12
  static #NEAREST_OWNER_DISTANCE_MIL = 20
12
13
  static #NEAREST_OWNER_AMBIGUITY_MARGIN_MIL = 2
13
14
  static #CLUSTER_CENTER_MAX_SPAN_MIL = 45
15
+ static #CENTERED_PACKAGE_OWNER_MAX_SPAN_MIL = 180
14
16
  static #GENERIC_SUBPART_OWNER_MAX_SPAN_MIL = 80
17
+ static #AUTHORED_MECHANICAL_TOKENS = new Set([
18
+ 'can',
19
+ 'clip',
20
+ 'cover',
21
+ 'enclosure',
22
+ 'frame',
23
+ 'hardware',
24
+ 'leg',
25
+ 'mechanical',
26
+ 'plate',
27
+ 'shield'
28
+ ])
15
29
  static #GENERIC_SUBPART_TOKENS = new Set(['plastic'])
16
30
 
17
31
  /**
@@ -19,8 +33,9 @@ export class PcbScene3dStaticBodyOwnerPromotion {
19
33
  * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
20
34
  * @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} [components] PCB components.
21
35
  * @param {{ centerX?: number, centerY?: number } | null} [board] Board context.
36
+ * @param {{ componentIndex?: number, x?: number, y?: number, hasTopPasteMaskOpening?: boolean, hasBottomPasteMaskOpening?: boolean }[]} [pads] PCB pads.
22
37
  */
23
- static promote(placementRows, components = [], board = null) {
38
+ static promote(placementRows, components = [], board = null, pads = []) {
24
39
  PcbScene3dStaticBodyOwnerPromotion.#promoteNearestOwners(
25
40
  placementRows,
26
41
  components
@@ -31,6 +46,12 @@ export class PcbScene3dStaticBodyOwnerPromotion {
31
46
  components
32
47
  )
33
48
 
49
+ PcbScene3dStaticBodyPadOwnerPromotion.promote(
50
+ placementRows,
51
+ components,
52
+ pads
53
+ )
54
+
34
55
  const clusters =
35
56
  PcbScene3dStaticBodyOwnerPromotion.#clusters(placementRows)
36
57
 
@@ -91,6 +112,11 @@ export class PcbScene3dStaticBodyOwnerPromotion {
91
112
  )
92
113
  .forEach((cluster) => {
93
114
  if (
115
+ cluster.indexes.some((index) =>
116
+ PcbScene3dStaticBodyOwnerPromotion.#hasLockedOwner(
117
+ placementRows[index]
118
+ )
119
+ ) ||
94
120
  cluster.indexes.length <= 1 ||
95
121
  PcbScene3dStaticBodyOwnerPromotion.#boundsMaxSpan(
96
122
  cluster.bounds
@@ -620,6 +646,10 @@ export class PcbScene3dStaticBodyOwnerPromotion {
620
646
  * @returns {boolean}
621
647
  */
622
648
  static #canPromote(row) {
649
+ if (PcbScene3dStaticBodyOwnerPromotion.#hasLockedOwner(row)) {
650
+ return false
651
+ }
652
+
623
653
  if (row.matchedComponent) {
624
654
  return true
625
655
  }
@@ -640,9 +670,14 @@ export class PcbScene3dStaticBodyOwnerPromotion {
640
670
  * @returns {boolean}
641
671
  */
642
672
  static #canPromoteNearestOwner(row) {
673
+ if (PcbScene3dStaticBodyOwnerPromotion.#hasLockedOwner(row)) {
674
+ return false
675
+ }
676
+
643
677
  if (
644
678
  PcbScene3dStaticBodyOwnerPromotion.#canPromote(row) ||
645
- PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(row)
679
+ PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(row) ||
680
+ PcbScene3dStaticBodyOwnerPromotion.#isCenteredPackageBody(row)
646
681
  ) {
647
682
  return true
648
683
  }
@@ -659,6 +694,15 @@ export class PcbScene3dStaticBodyOwnerPromotion {
659
694
  )
660
695
  }
661
696
 
697
+ /**
698
+ * Checks whether a placement already carries a deliberate static owner.
699
+ * @param {{ placement?: { ownerLocked?: boolean } } | null} row Placement row.
700
+ * @returns {boolean}
701
+ */
702
+ static #hasLockedOwner(row) {
703
+ return Boolean(row?.placement?.ownerLocked)
704
+ }
705
+
662
706
  /**
663
707
  * Checks whether one row is compact enough for cluster-center ownership.
664
708
  * @param {{ placement: object }} row Placement row.
@@ -672,6 +716,47 @@ export class PcbScene3dStaticBodyOwnerPromotion {
672
716
  )
673
717
  }
674
718
 
719
+ /**
720
+ * Checks whether one source-coordinate body looks like a complete package
721
+ * body centered on a component rather than authored board mechanics.
722
+ * @param {{ placement: object, matchedComponent: object | null }} row Placement row.
723
+ * @returns {boolean}
724
+ */
725
+ static #isCenteredPackageBody(row) {
726
+ const placement = row?.placement
727
+ const span = PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
728
+ placement?.geometry
729
+ )
730
+
731
+ return (
732
+ !row?.matchedComponent &&
733
+ placement?.sourceCoordinateFrame &&
734
+ !placement?.mountSideLocked &&
735
+ span > 40 &&
736
+ span <=
737
+ PcbScene3dStaticBodyOwnerPromotion
738
+ .#CENTERED_PACKAGE_OWNER_MAX_SPAN_MIL &&
739
+ !PcbScene3dStaticBodyOwnerPromotion.#hasAuthoredMechanicalIdentity(
740
+ placement
741
+ )
742
+ )
743
+ }
744
+
745
+ /**
746
+ * Checks whether one placement identity names authored board mechanics.
747
+ * @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
748
+ * @returns {boolean}
749
+ */
750
+ static #hasAuthoredMechanicalIdentity(placement) {
751
+ return PcbScene3dStaticBodyOwnerPromotion.#identityTokens(
752
+ placement
753
+ ).some((token) =>
754
+ PcbScene3dStaticBodyOwnerPromotion.#AUTHORED_MECHANICAL_TOKENS.has(
755
+ token
756
+ )
757
+ )
758
+ }
759
+
675
760
  /**
676
761
  * Checks whether a placement uses only the generic static-body label.
677
762
  * @param {{ designator?: string } | null} placement Static placement.
@@ -0,0 +1,378 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Promotes compact static bodies that sit exactly on component-owned pads.
7
+ */
8
+ export class PcbScene3dStaticBodyPadOwnerPromotion {
9
+ static #PAD_OWNER_DISTANCE_MIL = 5
10
+ static #PAD_OWNER_AMBIGUITY_MARGIN_MIL = 0.25
11
+ static #COMPACT_BODY_MAX_SPAN_MIL = 40
12
+ static #AUTHORED_MECHANICAL_TOKENS = new Set([
13
+ 'can',
14
+ 'clip',
15
+ 'cover',
16
+ 'enclosure',
17
+ 'frame',
18
+ 'hardware',
19
+ 'leg',
20
+ 'mechanical',
21
+ 'plate',
22
+ 'shield'
23
+ ])
24
+
25
+ /**
26
+ * Applies exact pad ownership to mutable static placement rows.
27
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
28
+ * @param {{ componentIndex?: number, designator?: string, layer?: string }[]} components PCB components.
29
+ * @param {{ componentIndex?: number, x?: number, y?: number, hasTopPasteMaskOpening?: boolean, hasBottomPasteMaskOpening?: boolean }[]} pads PCB pads.
30
+ * @returns {void}
31
+ */
32
+ static promote(placementRows, components = [], pads = []) {
33
+ const componentByIndex =
34
+ PcbScene3dStaticBodyPadOwnerPromotion.#componentByIndex(components)
35
+
36
+ placementRows.forEach((row) => {
37
+ if (!PcbScene3dStaticBodyPadOwnerPromotion.#canPromoteRow(row)) {
38
+ return
39
+ }
40
+
41
+ const owner =
42
+ PcbScene3dStaticBodyPadOwnerPromotion.#padOwnerForPlacement(
43
+ row.placement,
44
+ componentByIndex,
45
+ pads
46
+ )
47
+ if (!owner) {
48
+ return
49
+ }
50
+
51
+ row.matchedComponent = owner
52
+ row.placement = PcbScene3dStaticBodyPadOwnerPromotion.#withOwner(
53
+ row.placement,
54
+ owner
55
+ )
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Builds a component-index lookup.
61
+ * @param {object[]} components PCB components.
62
+ * @returns {Map<number, object>}
63
+ */
64
+ static #componentByIndex(components) {
65
+ const componentByIndex = new Map()
66
+
67
+ ;(Array.isArray(components) ? components : []).forEach((component) => {
68
+ const componentIndex = Number(component?.componentIndex)
69
+ if (
70
+ Number.isInteger(componentIndex) &&
71
+ String(component?.designator || '').trim()
72
+ ) {
73
+ componentByIndex.set(componentIndex, component)
74
+ }
75
+ })
76
+
77
+ return componentByIndex
78
+ }
79
+
80
+ /**
81
+ * Checks whether one static row is eligible for exact pad ownership.
82
+ * @param {{ placement?: object } | null} row Static placement row.
83
+ * @returns {boolean}
84
+ */
85
+ static #canPromoteRow(row) {
86
+ const placement = row?.placement
87
+
88
+ return (
89
+ placement &&
90
+ !placement.ownerLocked &&
91
+ !PcbScene3dStaticBodyPadOwnerPromotion.#hasAuthoredMechanicalIdentity(
92
+ placement
93
+ ) &&
94
+ PcbScene3dStaticBodyPadOwnerPromotion.#maxSpan(
95
+ placement.geometry
96
+ ) <=
97
+ PcbScene3dStaticBodyPadOwnerPromotion.#COMPACT_BODY_MAX_SPAN_MIL
98
+ )
99
+ }
100
+
101
+ /**
102
+ * Resolves the unique component whose surface pad owns a body anchor.
103
+ * @param {{ bodyPositionMil?: { x?: number, y?: number } }} placement Static placement.
104
+ * @param {Map<number, object>} componentByIndex Component lookup.
105
+ * @param {object[]} pads PCB pads.
106
+ * @returns {object | null}
107
+ */
108
+ static #padOwnerForPlacement(placement, componentByIndex, pads) {
109
+ const position =
110
+ PcbScene3dStaticBodyPadOwnerPromotion.#bodyPosition(placement)
111
+ if (!position) {
112
+ return null
113
+ }
114
+
115
+ const candidates = (Array.isArray(pads) ? pads : [])
116
+ .map((pad) =>
117
+ PcbScene3dStaticBodyPadOwnerPromotion.#padOwnerCandidate(
118
+ pad,
119
+ position,
120
+ componentByIndex
121
+ )
122
+ )
123
+ .filter(Boolean)
124
+ .sort((left, right) => left.distance - right.distance)
125
+
126
+ return PcbScene3dStaticBodyPadOwnerPromotion.#unambiguousOwner(
127
+ candidates
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Builds one exact pad-owner candidate.
133
+ * @param {object} pad PCB pad.
134
+ * @param {{ x: number, y: number }} position Body source position.
135
+ * @param {Map<number, object>} componentByIndex Component lookup.
136
+ * @returns {{ component: object, distance: number } | null}
137
+ */
138
+ static #padOwnerCandidate(pad, position, componentByIndex) {
139
+ const component = componentByIndex.get(Number(pad?.componentIndex))
140
+ if (
141
+ !component ||
142
+ !PcbScene3dStaticBodyPadOwnerPromotion.#isSurfacePadForComponent(
143
+ pad,
144
+ component
145
+ )
146
+ ) {
147
+ return null
148
+ }
149
+
150
+ const distance = Math.hypot(
151
+ Number(pad?.x || 0) - position.x,
152
+ Number(pad?.y || 0) - position.y
153
+ )
154
+
155
+ return Number.isFinite(distance) &&
156
+ distance <=
157
+ PcbScene3dStaticBodyPadOwnerPromotion.#PAD_OWNER_DISTANCE_MIL
158
+ ? { component, distance }
159
+ : null
160
+ }
161
+
162
+ /**
163
+ * Resolves an unambiguous owner from pad-distance candidates.
164
+ * @param {{ component: object, distance: number }[]} candidates Candidates.
165
+ * @returns {object | null}
166
+ */
167
+ static #unambiguousOwner(candidates) {
168
+ const nearest = candidates[0]
169
+ if (!nearest) {
170
+ return null
171
+ }
172
+
173
+ const nextDifferentOwner = candidates.find(
174
+ (candidate) =>
175
+ String(candidate.component?.designator || '') !==
176
+ String(nearest.component?.designator || '')
177
+ )
178
+
179
+ return nextDifferentOwner &&
180
+ nextDifferentOwner.distance - nearest.distance <=
181
+ PcbScene3dStaticBodyPadOwnerPromotion
182
+ .#PAD_OWNER_AMBIGUITY_MARGIN_MIL
183
+ ? null
184
+ : nearest.component
185
+ }
186
+
187
+ /**
188
+ * Resolves the static body source position.
189
+ * @param {{ bodyPositionMil?: { x?: number, y?: number } }} placement Static placement.
190
+ * @returns {{ x: number, y: number } | null}
191
+ */
192
+ static #bodyPosition(placement) {
193
+ const x = Number(placement?.bodyPositionMil?.x)
194
+ const y = Number(placement?.bodyPositionMil?.y)
195
+
196
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
197
+ }
198
+
199
+ /**
200
+ * Checks whether a pad belongs to the component's mounted PCB face.
201
+ * @param {object} pad PCB pad.
202
+ * @param {{ layer?: string }} component PCB component.
203
+ * @returns {boolean}
204
+ */
205
+ static #isSurfacePadForComponent(pad, component) {
206
+ return PcbScene3dStaticBodyPadOwnerPromotion.#componentMountSide(
207
+ component
208
+ ) === 'bottom'
209
+ ? Boolean(pad?.hasBottomPasteMaskOpening)
210
+ : Boolean(pad?.hasTopPasteMaskOpening)
211
+ }
212
+
213
+ /**
214
+ * Applies a component owner to a placement.
215
+ * @param {object} placement Static placement.
216
+ * @param {{ designator?: string, layer?: string }} owner Owner component.
217
+ * @returns {object}
218
+ */
219
+ static #withOwner(placement, owner) {
220
+ return {
221
+ ...PcbScene3dStaticBodyPadOwnerPromotion.#withMountSide(
222
+ placement,
223
+ PcbScene3dStaticBodyPadOwnerPromotion.#componentMountSide(owner)
224
+ ),
225
+ designator:
226
+ String(owner?.designator || '').trim() || placement?.designator
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Applies a mount side while preserving the placement's XY anchor.
232
+ * @param {object} placement Static placement.
233
+ * @param {string | undefined} mountSide Owner mount side.
234
+ * @returns {object}
235
+ */
236
+ static #withMountSide(placement, mountSide) {
237
+ const currentSide =
238
+ PcbScene3dStaticBodyPadOwnerPromotion.#normalizeMountSide(
239
+ placement?.mountSide
240
+ )
241
+ const side = PcbScene3dStaticBodyPadOwnerPromotion.#normalizeMountSide(
242
+ mountSide || currentSide
243
+ )
244
+ const z = Math.abs(Number(placement?.positionMil?.z || 0))
245
+
246
+ return {
247
+ ...placement,
248
+ mountSide: side,
249
+ positionMil: {
250
+ ...(placement?.positionMil || {}),
251
+ z: side === 'bottom' ? -z : z
252
+ },
253
+ geometry:
254
+ currentSide === side
255
+ ? placement?.geometry
256
+ : PcbScene3dStaticBodyPadOwnerPromotion.#mirrorGeometry(
257
+ placement
258
+ )
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Resolves the board side from one component layer.
264
+ * @param {{ layer?: string }} component PCB component.
265
+ * @returns {'top' | 'bottom'}
266
+ */
267
+ static #componentMountSide(component) {
268
+ return String(component?.layer || '')
269
+ .trim()
270
+ .toLowerCase()
271
+ .includes('bottom')
272
+ ? 'bottom'
273
+ : 'top'
274
+ }
275
+
276
+ /**
277
+ * Normalizes a mount-side token.
278
+ * @param {string | undefined} mountSide Candidate side.
279
+ * @returns {'top' | 'bottom'}
280
+ */
281
+ static #normalizeMountSide(mountSide) {
282
+ return String(mountSide || 'top')
283
+ .trim()
284
+ .toLowerCase() === 'bottom'
285
+ ? 'bottom'
286
+ : 'top'
287
+ }
288
+
289
+ /**
290
+ * Mirrors source-coordinate polygon geometry for a side change.
291
+ * @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static placement.
292
+ * @returns {object | undefined}
293
+ */
294
+ static #mirrorGeometry(placement) {
295
+ const geometry = placement?.geometry
296
+ if (
297
+ !placement?.sourceCoordinateFrame ||
298
+ !Array.isArray(geometry?.verticesMil)
299
+ ) {
300
+ return geometry
301
+ }
302
+
303
+ return {
304
+ ...geometry,
305
+ verticesMil: geometry.verticesMil.map((vertex) => ({
306
+ x: PcbScene3dStaticBodyPadOwnerPromotion.#roundMil(
307
+ Number(vertex?.x || 0)
308
+ ),
309
+ y: PcbScene3dStaticBodyPadOwnerPromotion.#roundMil(
310
+ -Number(vertex?.y || 0)
311
+ )
312
+ }))
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Checks whether a placement identity names authored board mechanics.
318
+ * @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
319
+ * @returns {boolean}
320
+ */
321
+ static #hasAuthoredMechanicalIdentity(placement) {
322
+ return PcbScene3dStaticBodyPadOwnerPromotion.#identityTokens(
323
+ placement
324
+ ).some((token) =>
325
+ PcbScene3dStaticBodyPadOwnerPromotion.#AUTHORED_MECHANICAL_TOKENS.has(
326
+ token
327
+ )
328
+ )
329
+ }
330
+
331
+ /**
332
+ * Collects normalized identity tokens from one placement.
333
+ * @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
334
+ * @returns {string[]}
335
+ */
336
+ static #identityTokens(placement) {
337
+ return [placement?.designator, placement?.sourceIdentityKey]
338
+ .join(' ')
339
+ .replace(/([a-z])([A-Z])/gu, '$1 $2')
340
+ .toLowerCase()
341
+ .split(/[^a-z0-9]+/g)
342
+ .flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
343
+ .filter(Boolean)
344
+ }
345
+
346
+ /**
347
+ * Resolves the largest horizontal body span.
348
+ * @param {object | undefined} geometry Static geometry.
349
+ * @returns {number}
350
+ */
351
+ static #maxSpan(geometry) {
352
+ const vertices = Array.isArray(geometry?.verticesMil)
353
+ ? geometry.verticesMil
354
+ : []
355
+ if (vertices.length) {
356
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
357
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
358
+
359
+ return Math.max(
360
+ Math.max(...xs) - Math.min(...xs),
361
+ Math.max(...ys) - Math.min(...ys)
362
+ )
363
+ }
364
+
365
+ const radius = Number(geometry?.radiusMil)
366
+ return Number.isFinite(radius) && radius > 0 ? radius * 2 : 0
367
+ }
368
+
369
+ /**
370
+ * Rounds one mil value for stable promoted geometry output.
371
+ * @param {number} value Candidate value.
372
+ * @returns {number}
373
+ */
374
+ static #roundMil(value) {
375
+ const rounded = Math.round(Number(value) * 10000) / 10000
376
+ return Object.is(rounded, -0) ? 0 : rounded
377
+ }
378
+ }
@@ -41,7 +41,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
41
41
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads Pads.
42
42
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
43
43
  * @param {number} thicknessMil Board thickness.
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 }[]}
44
+ * @returns {{ designator: string, selectionKey: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, ownerLocked?: boolean, geometry: object }[]}
45
45
  */
46
46
  static build(
47
47
  componentBodies,
@@ -71,21 +71,13 @@ export class PcbScene3dStaticBodyPlacementBuilder {
71
71
  PcbScene3dStaticBodyOwnerPromotion.promote(
72
72
  placementRows,
73
73
  components,
74
- board
74
+ board,
75
+ pads
75
76
  )
76
77
 
77
78
  return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
78
79
  }
79
80
 
80
- /**
81
- * Checks whether one static geometry is already complete.
82
- * @param {object | undefined} geometry Static geometry.
83
- * @returns {boolean}
84
- */
85
- static #isCompleteGeometry(geometry) {
86
- return Boolean(geometry && geometry.status === 'complete')
87
- }
88
-
89
81
  /**
90
82
  * Builds one static body placement row with its matched owner context.
91
83
  * @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.
@@ -93,7 +85,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
93
85
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
94
86
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
95
87
  * @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}
88
+ * @returns {{ placement: { designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, ownerLocked?: boolean, geometry: object }, matchedComponent: object | null } | null}
97
89
  */
98
90
  static #buildPlacementRow(
99
91
  componentBody,
@@ -125,7 +117,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
125
117
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
126
118
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
127
119
  * @param {number} thicknessMil Board thickness.
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}
120
+ * @returns {{ designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, ownerLocked?: boolean, geometry: object } | null}
129
121
  */
130
122
  static #buildPlacement(
131
123
  componentBody,
@@ -142,9 +134,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
142
134
  )
143
135
  const geometry = staticGeometry.geometry
144
136
 
145
- if (
146
- !PcbScene3dStaticBodyPlacementBuilder.#isCompleteGeometry(geometry)
147
- ) {
137
+ if (geometry?.status !== 'complete') {
148
138
  return null
149
139
  }
150
140
  const sourcePosition =
@@ -197,6 +187,11 @@ export class PcbScene3dStaticBodyPlacementBuilder {
197
187
  staticGeometry,
198
188
  mountSide
199
189
  )
190
+ const ownerLocked =
191
+ PcbScene3dStaticBodyPlacementBuilder.#hasLockedOwner(
192
+ componentBody,
193
+ matchedComponent
194
+ )
200
195
 
201
196
  return {
202
197
  designator:
@@ -211,7 +206,9 @@ export class PcbScene3dStaticBodyPlacementBuilder {
211
206
  sourceCoordinateFrame: Boolean(
212
207
  staticGeometry.sourceCoordinateFrame
213
208
  ),
209
+ ownerLocked,
214
210
  mountSideLocked:
211
+ ownerLocked ||
215
212
  PcbScene3dStaticBodyPlacementBuilder.#hasExplicitMountSide(
216
213
  componentBody,
217
214
  matchedComponent
@@ -280,6 +277,25 @@ export class PcbScene3dStaticBodyPlacementBuilder {
280
277
  return /^MECHANICAL\s*\d+$/i.test(layer)
281
278
  }
282
279
 
280
+ /**
281
+ * Checks whether a matched shield owner is deliberate enough to keep away
282
+ * from later nearest-neighbor fragment promotion.
283
+ * @param {{ identifier?: string, name?: string }} componentBody Component body.
284
+ * @param {{ pattern?: string, source?: string, description?: string, parameters?: object, provenance?: object } | null} matchedComponent Matched owner component.
285
+ * @returns {boolean}
286
+ */
287
+ static #hasLockedOwner(componentBody, matchedComponent) {
288
+ return Boolean(
289
+ matchedComponent &&
290
+ PcbScene3dStaticBodyPlacementBuilder.#hasGenericMechanicalIdentity(
291
+ componentBody
292
+ ) &&
293
+ PcbScene3dStaticBodyPlacementBuilder.#isShieldOwner(
294
+ matchedComponent
295
+ )
296
+ )
297
+ }
298
+
283
299
  /**
284
300
  * Checks whether one body identity looks like authored board mechanics.
285
301
  * @param {{ identifier?: string, name?: string, modelTypeName?: string }} componentBody Component body.
@@ -208,6 +208,7 @@ export class PcbScene3dStaticBodySymmetricOwnerPromotion {
208
208
  .filter(
209
209
  (candidate) =>
210
210
  candidate.position &&
211
+ !candidate.row?.placement?.ownerLocked &&
211
212
  candidate.row?.placement?.sourceCoordinateFrame &&
212
213
  PcbScene3dStaticBodySymmetricOwnerPromotion.#isCompactRow(
213
214
  candidate.row