altium-toolkit 1.1.35 → 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.
Files changed (35) hide show
  1. package/AGENTS.md +17 -2
  2. package/package.json +1 -1
  3. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  4. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  5. package/src/core/altium/SchematicTextParser.mjs +18 -253
  6. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  7. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  8. package/src/parser.mjs +1 -0
  9. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
  10. package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
  11. package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
  12. package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
  15. package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
  16. package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
  17. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
  18. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
  19. package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
  20. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
  21. package/src/ui/PcbScene3dBuilder.mjs +613 -117
  22. package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
  23. package/src/ui/PcbScene3dPackages.mjs +16 -1
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
  25. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
  26. package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
  27. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
  28. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  29. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
  30. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
  31. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
  32. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
  33. package/src/ui/SchematicColorResolver.mjs +44 -0
  34. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  35. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -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
+ }
@@ -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',
@@ -39,7 +41,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
39
41
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads Pads.
40
42
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
41
43
  * @param {number} thicknessMil Board thickness.
42
- * @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 }[]}
43
45
  */
44
46
  static build(
45
47
  componentBodies,
@@ -66,16 +68,14 @@ export class PcbScene3dStaticBodyPlacementBuilder {
66
68
  )
67
69
  .filter(Boolean)
68
70
 
69
- return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
70
- }
71
+ PcbScene3dStaticBodyOwnerPromotion.promote(
72
+ placementRows,
73
+ components,
74
+ board,
75
+ pads
76
+ )
71
77
 
72
- /**
73
- * Checks whether one static geometry is already complete.
74
- * @param {object | undefined} geometry Static geometry.
75
- * @returns {boolean}
76
- */
77
- static #isCompleteGeometry(geometry) {
78
- return Boolean(geometry && geometry.status === 'complete')
78
+ return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
79
79
  }
80
80
 
81
81
  /**
@@ -85,7 +85,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
85
85
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
86
86
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
87
87
  * @param {number} thicknessMil Board thickness.
88
- * @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}
89
89
  */
90
90
  static #buildPlacementRow(
91
91
  componentBody,
@@ -117,7 +117,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
117
117
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
118
118
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
119
119
  * @param {number} thicknessMil Board thickness.
120
- * @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}
121
121
  */
122
122
  static #buildPlacement(
123
123
  componentBody,
@@ -134,9 +134,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
134
134
  )
135
135
  const geometry = staticGeometry.geometry
136
136
 
137
- if (
138
- !PcbScene3dStaticBodyPlacementBuilder.#isCompleteGeometry(geometry)
139
- ) {
137
+ if (geometry?.status !== 'complete') {
140
138
  return null
141
139
  }
142
140
  const sourcePosition =
@@ -183,6 +181,17 @@ export class PcbScene3dStaticBodyPlacementBuilder {
183
181
  Number(thicknessMil || 0) / 2 +
184
182
  (Number.isFinite(standoffMil) ? standoffMil : 0) +
185
183
  heightMil / 2
184
+ const renderGeometry =
185
+ PcbScene3dStaticBodyPlacementBuilder.#placementGeometry(
186
+ geometry,
187
+ staticGeometry,
188
+ mountSide
189
+ )
190
+ const ownerLocked =
191
+ PcbScene3dStaticBodyPlacementBuilder.#hasLockedOwner(
192
+ componentBody,
193
+ matchedComponent
194
+ )
186
195
 
187
196
  return {
188
197
  designator:
@@ -194,11 +203,23 @@ export class PcbScene3dStaticBodyPlacementBuilder {
194
203
  PcbScene3dStaticBodyPlacementBuilder.#sourceIdentityKey(
195
204
  componentBody
196
205
  ),
197
- mountSide,
198
- rotationDeg: PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
199
- Number(componentBody.rotationDeg || 0) +
200
- Number(matchedComponent?.rotation || 0)
206
+ sourceCoordinateFrame: Boolean(
207
+ staticGeometry.sourceCoordinateFrame
201
208
  ),
209
+ ownerLocked,
210
+ mountSideLocked:
211
+ ownerLocked ||
212
+ PcbScene3dStaticBodyPlacementBuilder.#hasExplicitMountSide(
213
+ componentBody,
214
+ matchedComponent
215
+ ),
216
+ mountSide,
217
+ rotationDeg:
218
+ PcbScene3dStaticBodyPlacementBuilder.#placementRotationDeg(
219
+ componentBody,
220
+ matchedComponent,
221
+ staticGeometry
222
+ ),
202
223
  positionMil: {
203
224
  x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
204
225
  Number(sourcePosition.x || 0) - Number(board.centerX || 0)
@@ -217,7 +238,126 @@ export class PcbScene3dStaticBodyPlacementBuilder {
217
238
  ...PcbScene3dStaticBodyPlacementBuilder.#displayMetadata(
218
239
  componentBody
219
240
  ),
220
- geometry
241
+ geometry: renderGeometry
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Checks whether one body carries explicit top/bottom side evidence.
247
+ * @param {{ componentIndex?: number | string, layer?: string, standoffHeightMil?: number | string | null }} componentBody Component body.
248
+ * @param {{ layer?: string } | null} matchedComponent Matched owner component.
249
+ * @returns {boolean}
250
+ */
251
+ static #hasExplicitMountSide(componentBody, matchedComponent) {
252
+ if (
253
+ matchedComponent &&
254
+ Number.isInteger(Number(componentBody?.componentIndex))
255
+ ) {
256
+ return true
257
+ }
258
+
259
+ const layer = String(componentBody?.layer || '').trim()
260
+ if (!layer) {
261
+ const standoffMil = Number(componentBody?.standoffHeightMil)
262
+ return Number.isFinite(standoffMil) && standoffMil < 0
263
+ }
264
+
265
+ if (
266
+ !PcbScene3dStaticBodyPlacementBuilder.#isAuthoredMechanicalBody(
267
+ componentBody
268
+ )
269
+ ) {
270
+ return false
271
+ }
272
+
273
+ if (/(^|[^a-z])(top|bottom)([^a-z]|$)/i.test(layer)) {
274
+ return true
275
+ }
276
+
277
+ return /^MECHANICAL\s*\d+$/i.test(layer)
278
+ }
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
+
299
+ /**
300
+ * Checks whether one body identity looks like authored board mechanics.
301
+ * @param {{ identifier?: string, name?: string, modelTypeName?: string }} componentBody Component body.
302
+ * @returns {boolean}
303
+ */
304
+ static #isAuthoredMechanicalBody(componentBody) {
305
+ return PcbScene3dStaticBodyPlacementBuilder.#identityTokens(
306
+ componentBody
307
+ ).some((token) =>
308
+ PcbScene3dStaticBodyPlacementBuilder.#GENERIC_MECHANICAL_IDENTITY_TOKENS.has(
309
+ token
310
+ )
311
+ )
312
+ }
313
+
314
+ /**
315
+ * Resolves the rendered rotation for one static shape body.
316
+ * @param {{ rotationDeg?: number }} componentBody Component body.
317
+ * @param {{ rotation?: number } | null} matchedComponent Matched owner component.
318
+ * @param {{ sourceCoordinateFrame?: boolean }} normalizedStaticGeometry Normalized static geometry context.
319
+ * @returns {number}
320
+ */
321
+ static #placementRotationDeg(
322
+ componentBody,
323
+ matchedComponent,
324
+ normalizedStaticGeometry
325
+ ) {
326
+ const ownerRotation = normalizedStaticGeometry?.sourceCoordinateFrame
327
+ ? 0
328
+ : Number(matchedComponent?.rotation || 0)
329
+
330
+ return PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
331
+ Number(componentBody.rotationDeg || 0) + ownerRotation
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Converts normalized static geometry into the renderer mount-local frame.
337
+ * @param {object} geometry Normalized render geometry.
338
+ * @param {{ sourceCoordinateFrame?: boolean }} normalizedStaticGeometry Normalized static geometry context.
339
+ * @param {string} mountSide Resolved mount side.
340
+ * @returns {object}
341
+ */
342
+ static #placementGeometry(geometry, normalizedStaticGeometry, mountSide) {
343
+ if (
344
+ !normalizedStaticGeometry?.sourceCoordinateFrame ||
345
+ String(mountSide || 'top').toLowerCase() !== 'bottom' ||
346
+ !Array.isArray(geometry?.verticesMil)
347
+ ) {
348
+ return geometry
349
+ }
350
+
351
+ return {
352
+ ...geometry,
353
+ verticesMil: geometry.verticesMil.map((vertex) => ({
354
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
355
+ Number(vertex?.x || 0)
356
+ ),
357
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
358
+ -Number(vertex?.y || 0)
359
+ )
360
+ }))
221
361
  }
222
362
  }
223
363
 
@@ -226,19 +366,24 @@ export class PcbScene3dStaticBodyPlacementBuilder {
226
366
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
227
367
  * @param {object | undefined} geometry Static geometry.
228
368
  * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
229
- * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number } }}
369
+ * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number }, sourceCoordinateFrame: boolean }}
230
370
  */
231
371
  static #normalizeStaticGeometry(componentBody, geometry, matchedComponent) {
232
372
  if (
233
373
  String(geometry?.kind || '').toLowerCase() !== 'extruded-polygon' ||
234
374
  !Array.isArray(geometry?.verticesMil) ||
235
- geometry.verticesMil.length < 3 ||
375
+ geometry.verticesMil.length < 3
376
+ ) {
377
+ return { geometry, sourceCoordinateFrame: false }
378
+ }
379
+
380
+ if (
236
381
  !PcbScene3dStaticBodyPlacementBuilder.#usesSourceCoordinateFrame(
237
382
  componentBody,
238
383
  geometry.verticesMil
239
384
  )
240
385
  ) {
241
- return { geometry }
386
+ return { geometry, sourceCoordinateFrame: false }
242
387
  }
243
388
 
244
389
  const center =
@@ -255,6 +400,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
255
400
 
256
401
  return {
257
402
  placementCenterMil: placementCenter,
403
+ sourceCoordinateFrame: true,
258
404
  geometry: {
259
405
  ...geometry,
260
406
  verticesMil: geometry.verticesMil.map((vertex) => {