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
@@ -7,7 +7,10 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
7
7
  static #BODY_ANCHOR_TOLERANCE_MIL = 5
8
8
  static #AUTHORED_SOURCE = 'authored-body-anchor'
9
9
  static #AUTHORED_ANCHOR_IDENTITY_PATTERN =
10
- /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
10
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket)(?:$|[^a-z0-9])/i
11
+ static #USB_ANCHOR_IDENTITY_PATTERN = /(?:^|[^a-z0-9])usb(?:$|[^a-z0-9])/i
12
+ static #INTEGRATED_CIRCUIT_PACKAGE_PATTERN =
13
+ /(?:^|[^a-z0-9])(?:u?qfn|v?qfn|dfn|qfp|lqfp|tqfp|bga|lga|sop|soic|ssop|tssop|msop|so[-_ ]?\d+)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
11
14
 
12
15
  /**
13
16
  * Marks off-anchor explicit Altium body placements so the runtime does not
@@ -149,11 +152,36 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
149
152
  * @returns {boolean}
150
153
  */
151
154
  static #hasAuthoredAnchorIdentity(placement, component) {
152
- return AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_ANCHOR_IDENTITY_PATTERN.test(
155
+ const identityText =
153
156
  AltiumScene3dAuthoredBodyAnchorAdapter.#identityText(
154
157
  placement,
155
158
  component
156
159
  )
160
+
161
+ return (
162
+ AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_ANCHOR_IDENTITY_PATTERN.test(
163
+ identityText
164
+ ) ||
165
+ AltiumScene3dAuthoredBodyAnchorAdapter.#hasUsbHardwareAnchorIdentity(
166
+ identityText
167
+ )
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Allows USB connector-like anchors without treating USB interface ICs as
173
+ * authored mechanical anchors.
174
+ * @param {string} identityText Searchable package identity.
175
+ * @returns {boolean}
176
+ */
177
+ static #hasUsbHardwareAnchorIdentity(identityText) {
178
+ return (
179
+ AltiumScene3dAuthoredBodyAnchorAdapter.#USB_ANCHOR_IDENTITY_PATTERN.test(
180
+ identityText
181
+ ) &&
182
+ !AltiumScene3dAuthoredBodyAnchorAdapter.#INTEGRATED_CIRCUIT_PACKAGE_PATTERN.test(
183
+ identityText
184
+ )
157
185
  )
158
186
  }
159
187
 
@@ -752,8 +752,12 @@ export class AltiumScene3dExternalPlacementAdapter {
752
752
  180
753
753
  const cos = Math.cos(rotationRad)
754
754
  const sin = Math.sin(rotationRad)
755
- const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
756
- const y = Number(offset?.x || 0) * sin + Number(offset?.y || 0) * cos
755
+ const sourceY =
756
+ AltiumScene3dExternalPlacementAdapter.#isBottomPlacement(placement)
757
+ ? -Number(offset?.y || 0)
758
+ : Number(offset?.y || 0)
759
+ const x = Number(offset?.x || 0) * cos - sourceY * sin
760
+ const y = Number(offset?.x || 0) * sin + sourceY * cos
757
761
 
758
762
  return {
759
763
  x: Math.abs(x) < Number.EPSILON ? 0 : Number(x.toFixed(10)),
@@ -1,3 +1,5 @@
1
+ import { AltiumScene3dTwoRowFootprintDetector } from './AltiumScene3dTwoRowFootprintDetector.mjs'
2
+
1
3
  const PASSIVE_BODY_PATTERN =
2
4
  /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
3
5
  const CHIP_PASSIVE_BODY_PATTERN =
@@ -6,6 +8,8 @@ const LOCAL_Y_CHIP_PASSIVE_MODEL_PATTERN =
6
8
  /(?:^|[^a-z0-9])local[-_ ]?y(?:$|[^a-z0-9])/i
7
9
  const PIN_ONE_CORNER_PACKAGE_PATTERN =
8
10
  /(?:^|[^a-z0-9])(?:[avw]?qfn|vfqfn|wqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
11
+ const TWO_ROW_PIN_ONE_PACKAGE_PATTERN =
12
+ /(?:^|[^a-z0-9])(?:msop|qsop|soic|sop|ssop|tssop|tsop|vsop)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
9
13
  const FIVE_LEAD_SOT_PATTERN =
10
14
  /(?:^|[^a-z0-9])sot[-_ ]?(?:23[-_ ]?5|25|5)(?:$|[^a-z0-9])/i
11
15
  const THREE_LEAD_SOT23_PATTERN =
@@ -39,6 +43,9 @@ export class AltiumScene3dPlacementRotationPolicy {
39
43
  AltiumScene3dPlacementRotationPolicy.#needsSquarePinOneCorrection(
40
44
  context
41
45
  ) ||
46
+ AltiumScene3dPlacementRotationPolicy.#needsTwoRowPinOneCorrection(
47
+ context
48
+ ) ||
42
49
  AltiumScene3dPlacementRotationPolicy.#needsFiveLeadSotCorrection(
43
50
  context
44
51
  ) ||
@@ -211,6 +218,43 @@ export class AltiumScene3dPlacementRotationPolicy {
211
218
  )
212
219
  }
213
220
 
221
+ /**
222
+ * Detects two-row IC packages whose embedded source frame puts the
223
+ * package pin-one corner opposite the footprint convention.
224
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[] }} context Rotation context.
225
+ * @returns {boolean}
226
+ */
227
+ static #needsTwoRowPinOneCorrection(context) {
228
+ const { placement, component, componentBody, pads } = context || {}
229
+ if (
230
+ !AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement) ||
231
+ !component ||
232
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
233
+ AltiumScene3dPlacementRotationPolicy.#isGenericPassiveBody(
234
+ componentBody
235
+ ) ||
236
+ !AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
237
+ componentBody
238
+ )
239
+ ) {
240
+ return false
241
+ }
242
+
243
+ const identityText =
244
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
245
+ component,
246
+ componentBody
247
+ )
248
+ if (!TWO_ROW_PIN_ONE_PACKAGE_PATTERN.test(identityText)) {
249
+ return false
250
+ }
251
+
252
+ return AltiumScene3dTwoRowFootprintDetector.hasTwoRowSurfaceFootprint(
253
+ component,
254
+ pads
255
+ )
256
+ }
257
+
214
258
  /**
215
259
  * Checks whether a source package yaw already matches the footprint and
216
260
  * does not need another pin-one half-turn.
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dPackageDimensionResolver } from './PcbScene3dPackageDimensionResolver.mjs'
2
+
1
3
  const CONNECTOR_TOKENS = new Set([
2
4
  'antenna',
3
5
  'coax',
@@ -14,9 +16,14 @@ const TIMING_PACKAGE_PATTERN =
14
16
  /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
15
17
  const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
16
18
  const AUTHORED_ANCHOR_IDENTITY_PATTERN =
17
- /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
19
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket)(?:$|[^a-z0-9])/i
18
20
  const PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN =
19
- /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
21
+ /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket)(?:$|[^a-z0-9])/i
22
+ const USB_ANCHOR_IDENTITY_PATTERN = /(?:^|[^a-z0-9])usb(?:$|[^a-z0-9])/i
23
+ const INTEGRATED_CIRCUIT_PACKAGE_PATTERN =
24
+ /(?:^|[^a-z0-9])(?:u?qfn|v?qfn|dfn|qfp|lqfp|tqfp|bga|lga|sop|soic|ssop|tssop|msop|so[-_ ]?\d+)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
25
+ const SHIELD_COVER_TOKEN_PATTERN =
26
+ /(?:^|[^a-z0-9])(?:rf|emi|rfi|shield|cover)(?:$|[^a-z0-9])/gi
20
27
 
21
28
  /**
22
29
  * Repairs repeated Altium model-anchor bodies by matching their shared source
@@ -228,8 +235,20 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
228
235
  String(placement?.projection?.source || '').toLowerCase() ===
229
236
  'pad-fallback'
230
237
  ) {
238
+ if (
239
+ AltiumScene3dRepeatedModelOwnerRepair.#hasShieldCoverSourceOriginOffset(
240
+ placement,
241
+ component,
242
+ identityText
243
+ )
244
+ ) {
245
+ return false
246
+ }
247
+
231
248
  return (
232
- PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
249
+ AltiumScene3dRepeatedModelOwnerRepair.#hasPadFallbackAuthoredAnchorIdentity(
250
+ identityText
251
+ ) ||
233
252
  AltiumScene3dRepeatedModelOwnerRepair.#hasCompactIcSourceOriginOffset(
234
253
  placement,
235
254
  component,
@@ -238,7 +257,149 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
238
257
  )
239
258
  }
240
259
 
241
- return AUTHORED_ANCHOR_IDENTITY_PATTERN.test(identityText)
260
+ return AltiumScene3dRepeatedModelOwnerRepair.#hasAuthoredAnchorIdentity(
261
+ identityText
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Checks whether pad-fallback identity describes an authored hardware
267
+ * anchor instead of a source-origin-biased IC body.
268
+ * @param {string} identityText Searchable package identity.
269
+ * @returns {boolean}
270
+ */
271
+ static #hasPadFallbackAuthoredAnchorIdentity(identityText) {
272
+ return (
273
+ PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
274
+ AltiumScene3dRepeatedModelOwnerRepair.#hasUsbHardwareAnchorIdentity(
275
+ identityText
276
+ )
277
+ )
278
+ }
279
+
280
+ /**
281
+ * Checks whether non-pad-fallback identity describes authored hardware.
282
+ * @param {string} identityText Searchable package identity.
283
+ * @returns {boolean}
284
+ */
285
+ static #hasAuthoredAnchorIdentity(identityText) {
286
+ return (
287
+ AUTHORED_ANCHOR_IDENTITY_PATTERN.test(identityText) ||
288
+ AltiumScene3dRepeatedModelOwnerRepair.#hasUsbHardwareAnchorIdentity(
289
+ identityText
290
+ )
291
+ )
292
+ }
293
+
294
+ /**
295
+ * Allows USB connector-like anchors without treating USB interface ICs as
296
+ * authored mechanical anchors.
297
+ * @param {string} identityText Searchable package identity.
298
+ * @returns {boolean}
299
+ */
300
+ static #hasUsbHardwareAnchorIdentity(identityText) {
301
+ return (
302
+ USB_ANCHOR_IDENTITY_PATTERN.test(identityText) &&
303
+ !INTEGRATED_CIRCUIT_PACKAGE_PATTERN.test(identityText)
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Checks whether a shield cover body origin is a package corner.
309
+ * @param {object} placement Scene placement.
310
+ * @param {object} component PCB component.
311
+ * @param {string} identityText Searchable package identity.
312
+ * @returns {boolean}
313
+ */
314
+ static #hasShieldCoverSourceOriginOffset(
315
+ placement,
316
+ component,
317
+ identityText
318
+ ) {
319
+ const tokens = new Set(
320
+ [...String(identityText || '').matchAll(SHIELD_COVER_TOKEN_PATTERN)]
321
+ .map((match) => String(match[0] || '').toLowerCase())
322
+ .map((token) => token.replace(/[^a-z0-9]+/giu, ''))
323
+ )
324
+ if (
325
+ !tokens.has('cover') ||
326
+ !['rf', 'emi', 'rfi', 'shield'].some((token) => tokens.has(token))
327
+ ) {
328
+ return false
329
+ }
330
+
331
+ const size =
332
+ PcbScene3dPackageDimensionResolver.resolvePlanarSize(component)
333
+ if (!size) {
334
+ return false
335
+ }
336
+
337
+ const offset = {
338
+ x:
339
+ Number(placement?.bodyPositionMil?.x || 0) -
340
+ Number(component?.x || 0),
341
+ y:
342
+ Number(placement?.bodyPositionMil?.y || 0) -
343
+ Number(component?.y || 0)
344
+ }
345
+ if (
346
+ !Number.isFinite(offset.x) ||
347
+ !Number.isFinite(offset.y) ||
348
+ Math.hypot(offset.x, offset.y) <=
349
+ AltiumScene3dRepeatedModelOwnerRepair
350
+ .#MIN_PAD_FALLBACK_OWNER_OFFSET_MIL
351
+ ) {
352
+ return false
353
+ }
354
+
355
+ return AltiumScene3dRepeatedModelOwnerRepair.#matchesCornerOriginSize(
356
+ Math.abs(offset.x) * 2,
357
+ Math.abs(offset.y) * 2,
358
+ size
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Checks whether doubled owner offset matches package dimensions.
364
+ * @param {number} offsetWidth Doubled X offset.
365
+ * @param {number} offsetDepth Doubled Y offset.
366
+ * @param {{ width: number, depth: number }} size Explicit package size.
367
+ * @returns {boolean}
368
+ */
369
+ static #matchesCornerOriginSize(offsetWidth, offsetDepth, size) {
370
+ return (
371
+ (AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
372
+ offsetWidth,
373
+ size.width
374
+ ) &&
375
+ AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
376
+ offsetDepth,
377
+ size.depth
378
+ )) ||
379
+ (AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
380
+ offsetWidth,
381
+ size.depth
382
+ ) &&
383
+ AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
384
+ offsetDepth,
385
+ size.width
386
+ ))
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Checks whether two dimensions are close enough for source-origin repair.
392
+ * @param {number} actual Actual dimension.
393
+ * @param {number} expected Expected dimension.
394
+ * @returns {boolean}
395
+ */
396
+ static #matchesDimension(actual, expected) {
397
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
398
+ return false
399
+ }
400
+
401
+ const tolerance = Math.max(35, Math.abs(expected) * 0.08)
402
+ return Math.abs(actual - expected) <= tolerance
242
403
  }
243
404
 
244
405
  /**
@@ -557,8 +718,12 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
557
718
  180
558
719
  const cos = Math.cos(rotationRad)
559
720
  const sin = Math.sin(rotationRad)
560
- const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
561
- const y = Number(offset?.x || 0) * sin + Number(offset?.y || 0) * cos
721
+ const sourceY =
722
+ AltiumScene3dRepeatedModelOwnerRepair.#isBottomPlacement(placement)
723
+ ? -Number(offset?.y || 0)
724
+ : Number(offset?.y || 0)
725
+ const x = Number(offset?.x || 0) * cos - sourceY * sin
726
+ const y = Number(offset?.x || 0) * sin + sourceY * cos
562
727
  const z = Number(
563
728
  modelTransform?.offsetMil?.z ?? modelTransform?.dzMil ?? 0
564
729
  )
@@ -7,8 +7,11 @@ const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
7
7
  */
8
8
  export class AltiumScene3dShapeStackOwnerAdapter {
9
9
  static #BASE_OWNER_TOLERANCE_MIL = 60
10
+ static #EXACT_OWNER_TOLERANCE_MIL = 1
10
11
  static #STACK_BODY_RADIUS_MIL = 220
11
12
  static #HEIGHT_TOLERANCE_MIL = 0.1
13
+ static #TIMING_PARAMETER_NAME_PATTERN =
14
+ /(?:^|[^a-z0-9])(?:comment|description|device|function|part|type|value)(?:$|[^a-z0-9])/i
12
15
 
13
16
  /**
14
17
  * Applies authored stack ownership to static and external placements.
@@ -69,7 +72,8 @@ export class AltiumScene3dShapeStackOwnerAdapter {
69
72
  AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
70
73
  sceneDescription.staticBodyPlacements,
71
74
  componentBodies,
72
- assignments
75
+ assignments,
76
+ sceneDescription.board
73
77
  )
74
78
  }
75
79
  }
@@ -146,9 +150,15 @@ export class AltiumScene3dShapeStackOwnerAdapter {
146
150
  * @param {object[] | undefined} placements Scene static body placements.
147
151
  * @param {object[]} componentBodies Source component-body rows.
148
152
  * @param {object[]} assignments Carrier assignments.
153
+ * @param {object | undefined} board Scene board metadata.
149
154
  * @returns {object[]}
150
155
  */
151
- static #repairStaticPlacements(placements, componentBodies, assignments) {
156
+ static #repairStaticPlacements(
157
+ placements,
158
+ componentBodies,
159
+ assignments,
160
+ board
161
+ ) {
152
162
  return (Array.isArray(placements) ? placements : []).map(
153
163
  (placement) => {
154
164
  const assignment = assignments.find((candidate) =>
@@ -161,15 +171,122 @@ export class AltiumScene3dShapeStackOwnerAdapter {
161
171
  return placement
162
172
  }
163
173
 
164
- return {
165
- ...placement,
166
- designator: String(assignment.owner.designator || ''),
167
- coLocatedVariantGroupKey: assignment.groupKey
168
- }
174
+ return AltiumScene3dShapeStackOwnerAdapter.#withStaticOwner(
175
+ placement,
176
+ assignment,
177
+ board
178
+ )
169
179
  }
170
180
  )
171
181
  }
172
182
 
183
+ /**
184
+ * Applies one stack owner to a repaired static carrier body.
185
+ * @param {object} placement Static body placement.
186
+ * @param {{ owner: object, groupKey: string }} assignment Stack assignment.
187
+ * @param {object | undefined} board Scene board metadata.
188
+ * @returns {object}
189
+ */
190
+ static #withStaticOwner(placement, assignment, board) {
191
+ const ownerDesignator = String(assignment?.owner?.designator || '')
192
+ const mountSide =
193
+ AltiumScene3dShapeStackOwnerAdapter.#mountSide(assignment?.owner) ||
194
+ placement?.mountSide
195
+ const sidePlacement =
196
+ AltiumScene3dShapeStackOwnerAdapter.#withStaticMountSide(
197
+ placement,
198
+ mountSide
199
+ )
200
+
201
+ return {
202
+ ...sidePlacement,
203
+ designator: ownerDesignator,
204
+ selectionKey: ownerDesignator || sidePlacement.selectionKey,
205
+ positionMil: AltiumScene3dShapeStackOwnerAdapter.#withOwnerAnchor(
206
+ sidePlacement?.positionMil,
207
+ assignment.owner,
208
+ board
209
+ ),
210
+ coLocatedVariantGroupKey: assignment.groupKey
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Applies a static placement side while preserving its height magnitude.
216
+ * @param {object} placement Static body placement.
217
+ * @param {string | undefined} mountSide Owner mount side.
218
+ * @returns {object}
219
+ */
220
+ static #withStaticMountSide(placement, mountSide) {
221
+ const currentSide =
222
+ AltiumScene3dShapeStackOwnerAdapter.#normalizeMountSide(
223
+ placement?.mountSide
224
+ )
225
+ const side = AltiumScene3dShapeStackOwnerAdapter.#normalizeMountSide(
226
+ mountSide || currentSide
227
+ )
228
+ const z = Math.abs(Number(placement?.positionMil?.z || 0))
229
+
230
+ return {
231
+ ...placement,
232
+ mountSide: side,
233
+ positionMil: {
234
+ ...(placement?.positionMil || {}),
235
+ z: side === 'bottom' ? -z : z
236
+ },
237
+ geometry:
238
+ currentSide === side
239
+ ? placement?.geometry
240
+ : AltiumScene3dShapeStackOwnerAdapter.#mirrorStaticGeometry(
241
+ placement
242
+ )
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Mirrors source-coordinate static geometry for the opposite board side.
248
+ * @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static body placement.
249
+ * @returns {object | undefined}
250
+ */
251
+ static #mirrorStaticGeometry(placement) {
252
+ const geometry = placement?.geometry
253
+ if (
254
+ !placement?.sourceCoordinateFrame ||
255
+ !Array.isArray(geometry?.verticesMil)
256
+ ) {
257
+ return geometry
258
+ }
259
+
260
+ return {
261
+ ...geometry,
262
+ verticesMil: geometry.verticesMil.map((vertex) => ({
263
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(vertex?.x || 0),
264
+ y: AltiumScene3dShapeStackOwnerAdapter.#round(
265
+ -Number(vertex?.y || 0)
266
+ )
267
+ }))
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Re-anchors a repaired static carrier on the owning component position.
273
+ * @param {object | undefined} position Existing render position.
274
+ * @param {{ x?: number, y?: number }} owner Owner component.
275
+ * @param {object | undefined} board Scene board metadata.
276
+ * @returns {{ x: number, y: number, z: number }}
277
+ */
278
+ static #withOwnerAnchor(position, owner, board) {
279
+ return {
280
+ ...(position || {}),
281
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(
282
+ Number(owner?.x || 0) - Number(board?.centerX || 0)
283
+ ),
284
+ y: AltiumScene3dShapeStackOwnerAdapter.#round(
285
+ Number(owner?.y || 0) - Number(board?.centerY || 0)
286
+ )
287
+ }
288
+ }
289
+
173
290
  /**
174
291
  * Builds carrier-base to owner-component assignments.
175
292
  * @param {object[]} componentBodies Source component-body rows.
@@ -187,6 +304,15 @@ export class AltiumScene3dShapeStackOwnerAdapter {
187
304
  AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
188
305
 
189
306
  return groups.flatMap((group) => {
307
+ if (
308
+ AltiumScene3dShapeStackOwnerAdapter.#hasExactNonTimingOwner(
309
+ group.anchor,
310
+ components
311
+ )
312
+ ) {
313
+ return []
314
+ }
315
+
190
316
  const owners = timingComponents
191
317
  .filter(
192
318
  (component) =>
@@ -242,6 +368,39 @@ export class AltiumScene3dShapeStackOwnerAdapter {
242
368
  })
243
369
  }
244
370
 
371
+ /**
372
+ * Checks whether one carrier base already sits on a non-timing component.
373
+ * @param {{ x?: number, y?: number }} anchor Carrier base anchor.
374
+ * @param {object[]} components Source components.
375
+ * @returns {boolean}
376
+ */
377
+ static #hasExactNonTimingOwner(anchor, components) {
378
+ const exactOwners = (
379
+ Array.isArray(components) ? components : []
380
+ ).filter(
381
+ (component) =>
382
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
383
+ anchor,
384
+ component
385
+ ) <=
386
+ AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
387
+ )
388
+
389
+ return (
390
+ exactOwners.some(
391
+ (component) =>
392
+ !AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
393
+ component
394
+ )
395
+ ) &&
396
+ !exactOwners.some((component) =>
397
+ AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
398
+ component
399
+ )
400
+ )
401
+ )
402
+ }
403
+
245
404
  /**
246
405
  * Groups carrier bases that share one source anchor.
247
406
  * @param {object[]} bases Candidate carrier bases.
@@ -469,7 +628,11 @@ export class AltiumScene3dShapeStackOwnerAdapter {
469
628
  return (
470
629
  componentBodies
471
630
  .filter(
472
- (componentBody) => !usedComponentBodies.has(componentBody)
631
+ (componentBody) =>
632
+ !usedComponentBodies.has(componentBody) &&
633
+ !AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(
634
+ componentBody
635
+ )
473
636
  )
474
637
  .map((componentBody) => ({
475
638
  componentBody,
@@ -551,15 +714,9 @@ export class AltiumScene3dShapeStackOwnerAdapter {
551
714
  return (
552
715
  TIMING_DESIGNATOR_PATTERN.test(designator) ||
553
716
  TIMING_PACKAGE_PATTERN.test(
554
- [
555
- component?.pattern,
556
- component?.source,
557
- component?.description,
558
- component?.provenance?.footprintDescription,
559
- ...Object.values(component?.parameters || {})
560
- ]
561
- .map((value) => String(value || ''))
562
- .join(' ')
717
+ AltiumScene3dShapeStackOwnerAdapter.#timingIdentityText(
718
+ component
719
+ )
563
720
  )
564
721
  )
565
722
  }
@@ -612,6 +769,19 @@ export class AltiumScene3dShapeStackOwnerAdapter {
612
769
  return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
613
770
  }
614
771
 
772
+ /**
773
+ * Normalizes a static placement side token.
774
+ * @param {string | undefined} mountSide Candidate mount side.
775
+ * @returns {'top' | 'bottom'}
776
+ */
777
+ static #normalizeMountSide(mountSide) {
778
+ return String(mountSide || 'top')
779
+ .trim()
780
+ .toLowerCase() === 'bottom'
781
+ ? 'bottom'
782
+ : 'top'
783
+ }
784
+
615
785
  /**
616
786
  * Resolves one board face Z coordinate.
617
787
  * @param {string} mountSide Mount side.
@@ -691,6 +861,44 @@ export class AltiumScene3dShapeStackOwnerAdapter {
691
861
  .join(' ')
692
862
  }
693
863
 
864
+ /**
865
+ * Builds timing-relevant identity text without material metadata.
866
+ * @param {object} component Source component.
867
+ * @returns {string}
868
+ */
869
+ static #timingIdentityText(component) {
870
+ return [
871
+ component?.pattern,
872
+ component?.source,
873
+ component?.description,
874
+ component?.provenance?.footprintDescription,
875
+ ...AltiumScene3dShapeStackOwnerAdapter.#timingParameterValues(
876
+ component?.parameters
877
+ )
878
+ ]
879
+ .map((value) => String(value || ''))
880
+ .join(' ')
881
+ }
882
+
883
+ /**
884
+ * Returns parameter values whose names describe component identity.
885
+ * @param {object | undefined} parameters Source component parameters.
886
+ * @returns {unknown[]}
887
+ */
888
+ static #timingParameterValues(parameters) {
889
+ if (!parameters || typeof parameters !== 'object') {
890
+ return []
891
+ }
892
+
893
+ return Object.entries(parameters)
894
+ .filter(([key]) =>
895
+ AltiumScene3dShapeStackOwnerAdapter.#TIMING_PARAMETER_NAME_PATTERN.test(
896
+ String(key || '')
897
+ )
898
+ )
899
+ .map(([, value]) => value)
900
+ }
901
+
694
902
  /**
695
903
  * Rounds one mil value for stable scene output.
696
904
  * @param {number} value Candidate value.