altium-toolkit 1.1.32 → 1.1.35

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 (28) hide show
  1. package/docs/model-format.md +2 -3
  2. package/package.json +1 -1
  3. package/src/core/altium/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
  6. package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
  7. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  8. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
  9. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  10. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  11. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  12. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +27 -2
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  15. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  16. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
  17. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  18. package/src/ui/PcbScene3dBuilder.mjs +595 -28
  19. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  20. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  21. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  22. package/src/ui/PcbScene3dPackages.mjs +15 -3
  23. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +56 -0
  25. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
  26. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
  27. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
  28. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
@@ -121,10 +121,11 @@ export class PcbShapeBasedBodyGeometryParser {
121
121
  }
122
122
 
123
123
  const textBytes = bytes.subarray(offset + 22, offset + 22 + textLength)
124
- const text = PrintableTextDecoder.decodeBytes(textBytes).replaceAll(
125
- '\u0000',
126
- ''
127
- )
124
+ const payload =
125
+ PcbShapeBasedBodyGeometryParser.#splitInlineVertexPayload(textBytes)
126
+ const text = PrintableTextDecoder.decodeBytes(
127
+ payload.fieldBytes
128
+ ).replaceAll('\u0000', '')
128
129
 
129
130
  if (
130
131
  !text.includes('MODEL.') ||
@@ -133,11 +134,17 @@ export class PcbShapeBasedBodyGeometryParser {
133
134
  return null
134
135
  }
135
136
 
136
- const fields =
137
- PcbShapeBasedBodyGeometryParser.#parseFieldRecordBytes(textBytes)
137
+ const fields = PcbShapeBasedBodyGeometryParser.#parseFieldRecordBytes(
138
+ payload.fieldBytes
139
+ )
140
+ const textEndOffset = offset + 22 + textLength
141
+ const vertexBlockOffset =
142
+ payload.inlineVertexBlockOffset === null
143
+ ? textEndOffset
144
+ : offset + 22 + payload.inlineVertexBlockOffset
138
145
  const vertexBlock = PcbShapeBasedBodyGeometryParser.#parseVertexBlock(
139
146
  bytes,
140
- offset + 22 + textLength
147
+ vertexBlockOffset
141
148
  )
142
149
 
143
150
  return {
@@ -146,13 +153,80 @@ export class PcbShapeBasedBodyGeometryParser {
146
153
  fields,
147
154
  vertexBlock.verticesMil
148
155
  ),
149
- nextOffset:
150
- vertexBlock.nextOffset > offset
151
- ? vertexBlock.nextOffset
152
- : offset + 22 + textLength
156
+ nextOffset: Math.max(vertexBlock.nextOffset, textEndOffset)
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Splits printable fields from Altium records whose text length also
162
+ * covers an inline binary vertex block after a NUL terminator.
163
+ * @param {Uint8Array} textBytes Declared text payload bytes.
164
+ * @returns {{ fieldBytes: Uint8Array, inlineVertexBlockOffset: number | null }}
165
+ */
166
+ static #splitInlineVertexPayload(textBytes) {
167
+ const inlineVertexBlockOffset =
168
+ PcbShapeBasedBodyGeometryParser.#inlineVertexBlockOffset(textBytes)
169
+ if (inlineVertexBlockOffset === null) {
170
+ return { fieldBytes: textBytes, inlineVertexBlockOffset: null }
171
+ }
172
+
173
+ return {
174
+ fieldBytes: textBytes.subarray(0, inlineVertexBlockOffset - 1),
175
+ inlineVertexBlockOffset
153
176
  }
154
177
  }
155
178
 
179
+ /**
180
+ * Finds the inline vertex-count offset inside a declared text payload.
181
+ * @param {Uint8Array} textBytes Declared text payload bytes.
182
+ * @returns {number | null}
183
+ */
184
+ static #inlineVertexBlockOffset(textBytes) {
185
+ for (let index = 0; index < textBytes.byteLength; index += 1) {
186
+ if (textBytes[index] !== 0) {
187
+ continue
188
+ }
189
+
190
+ const vertexBlockOffset = index + 1
191
+ if (
192
+ PcbShapeBasedBodyGeometryParser.#hasPlausibleVertexBlock(
193
+ textBytes,
194
+ vertexBlockOffset
195
+ )
196
+ ) {
197
+ return vertexBlockOffset
198
+ }
199
+ }
200
+
201
+ return null
202
+ }
203
+
204
+ /**
205
+ * Checks whether one byte offset points to a complete vertex block.
206
+ * @param {Uint8Array} bytes Candidate payload bytes.
207
+ * @param {number} offset Candidate vertex-count offset.
208
+ * @returns {boolean}
209
+ */
210
+ static #hasPlausibleVertexBlock(bytes, offset) {
211
+ if (offset + 4 > bytes.byteLength) {
212
+ return false
213
+ }
214
+
215
+ const view = new DataView(
216
+ bytes.buffer,
217
+ bytes.byteOffset,
218
+ bytes.byteLength
219
+ )
220
+ const vertexCount = view.getInt32(offset, true)
221
+ const dataOffset = offset + 4
222
+
223
+ return (
224
+ vertexCount >= 3 &&
225
+ vertexCount <= 10000 &&
226
+ dataOffset + vertexCount * 37 <= bytes.byteLength
227
+ )
228
+ }
229
+
156
230
  /**
157
231
  * Parses the optional coordinate block after a shape-body field record.
158
232
  * @param {Uint8Array} bytes Stream bytes.
@@ -137,10 +137,25 @@ export class SourceComponentBundleNormalizer {
137
137
  primitives: SourceComponentBundleNormalizer.#array(
138
138
  footprint.primitives || footprint.shapes
139
139
  ),
140
+ component:
141
+ SourceComponentBundleNormalizer.#normalizeFootprintComponent(
142
+ footprint.component || source.component
143
+ ),
140
144
  raw: footprint
141
145
  }
142
146
  }
143
147
 
148
+ /**
149
+ * Preserves source footprint component placement metadata for exporters.
150
+ * @param {object | undefined} component Source component placement.
151
+ * @returns {object}
152
+ */
153
+ static #normalizeFootprintComponent(component) {
154
+ return component && typeof component === 'object'
155
+ ? { ...component }
156
+ : {}
157
+ }
158
+
144
159
  /**
145
160
  * Normalizes model asset descriptors.
146
161
  * @param {object} source Unwrapped response.
@@ -177,6 +192,8 @@ export class SourceComponentBundleNormalizer {
177
192
  )
178
193
  const bytes =
179
194
  SourceComponentBundleNormalizer.#normalizeModelBytes(model)
195
+ const transform =
196
+ SourceComponentBundleNormalizer.#normalizeModelTransform(model)
180
197
 
181
198
  return {
182
199
  id: SourceComponentBundleNormalizer.#firstString(
@@ -199,10 +216,24 @@ export class SourceComponentBundleNormalizer {
199
216
  model.downloadUrl,
200
217
  model.sourceUrl
201
218
  ),
219
+ ...(transform ? { transform } : {}),
220
+ ...(model.generated === true ? { generated: true } : {}),
202
221
  raw: model
203
222
  }
204
223
  }
205
224
 
225
+ /**
226
+ * Preserves optional model placement transforms.
227
+ * @param {object} model Raw model asset.
228
+ * @returns {object | null}
229
+ */
230
+ static #normalizeModelTransform(model) {
231
+ const transform = model.transform || model.modelTransform || null
232
+ return transform && typeof transform === 'object'
233
+ ? { ...transform }
234
+ : null
235
+ }
236
+
206
237
  /**
207
238
  * Normalizes one model payload into bytes.
208
239
  * @param {object} model Raw model asset.
@@ -6,7 +6,7 @@
6
6
  * Defines the Circuit JSON contract emitted by public parser roots.
7
7
  */
8
8
  export class CircuitJsonModelSchema {
9
- static CURRENT_SCHEMA_ID = 'https://github.com/tscircuit/circuit-json'
9
+ static CURRENT_SCHEMA_ID = 'urn:altium-toolkit:circuit-json:0.0.433'
10
10
 
11
11
  static CURRENT_SCHEMA_VERSION = '0.0.433'
12
12
 
@@ -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
 
@@ -89,6 +89,14 @@ export class AltiumScene3dExternalPlacementAdapter {
89
89
  if (!placement?.bodyPositionMil || !placement?.positionMil) {
90
90
  return placement
91
91
  }
92
+ if (
93
+ AltiumScene3dExternalPlacementAdapter.#isAuthoredShapeStackPlacement(
94
+ placement
95
+ )
96
+ ) {
97
+ return placement
98
+ }
99
+
92
100
  const componentBody =
93
101
  AltiumScene3dExternalPlacementAdapter.#resolveComponentBody(
94
102
  placement,
@@ -282,6 +290,19 @@ export class AltiumScene3dExternalPlacementAdapter {
282
290
  }
283
291
  }
284
292
 
293
+ /**
294
+ * Checks whether an earlier adapter already resolved an authored carrier
295
+ * stack placement.
296
+ * @param {object} placement External model placement.
297
+ * @returns {boolean}
298
+ */
299
+ static #isAuthoredShapeStackPlacement(placement) {
300
+ return (
301
+ String(placement?.projection?.source || '').toLowerCase() ===
302
+ 'authored-shape-stack'
303
+ )
304
+ }
305
+
285
306
  /**
286
307
  * Resolves board-local SMT pad centers for mixed connector footprints.
287
308
  * @param {object} placement External model placement.
@@ -731,8 +752,12 @@ export class AltiumScene3dExternalPlacementAdapter {
731
752
  180
732
753
  const cos = Math.cos(rotationRad)
733
754
  const sin = Math.sin(rotationRad)
734
- const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
735
- 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
736
761
 
737
762
  return {
738
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
  )