altium-toolkit 1.1.36 → 1.1.38
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.
- package/AGENTS.md +15 -0
- package/package.json +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
- package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
- package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
- package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +762 -19
- package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
- package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +180 -158
- package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
- package/src/ui/PcbScene3dBuilder.mjs +613 -117
- package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +87 -2
- package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +32 -16
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +1 -0
package/AGENTS.md
CHANGED
|
@@ -40,6 +40,21 @@
|
|
|
40
40
|
- Keep parser and renderer fixes universal. Never special-case a specific file
|
|
41
41
|
name, project identifier, fixture helper, or source-derived phrase.
|
|
42
42
|
|
|
43
|
+
## Fix Quality Rules
|
|
44
|
+
|
|
45
|
+
- Fixes must always address the general behavior, not a specific example,
|
|
46
|
+
fixture, file, project, or test case.
|
|
47
|
+
- Do not implement workarounds, cheats, allowlists, hard-coded example handling,
|
|
48
|
+
or special-case logic to make one sample pass.
|
|
49
|
+
- When you encounter existing workaround code, cheating behavior, or
|
|
50
|
+
example-specific handling, rewrite it into general-purpose behavior
|
|
51
|
+
immediately when it is in scope for the change.
|
|
52
|
+
- Keep fixes universal and structural: derive behavior from the underlying data
|
|
53
|
+
model, format, protocol, or UI contract instead of matching known sample text,
|
|
54
|
+
filenames, labels, or project identifiers.
|
|
55
|
+
- After fixing code, run the appropriate repo-owned tests and do not modify tests
|
|
56
|
+
just to make a workaround pass.
|
|
57
|
+
|
|
43
58
|
## Testing Guidelines
|
|
44
59
|
|
|
45
60
|
- Use repo scripts only: `npm test`.
|
package/package.json
CHANGED
|
@@ -35,6 +35,13 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
35
35
|
return sceneDescription
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const repeatedPlacementKeys =
|
|
39
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#repeatedPlacementKeys(
|
|
40
|
+
sceneDescription.externalPlacements
|
|
41
|
+
)
|
|
42
|
+
const pads = Array.isArray(sceneDescription?.detail?.pads)
|
|
43
|
+
? sceneDescription.detail.pads
|
|
44
|
+
: []
|
|
38
45
|
let changed = false
|
|
39
46
|
const externalPlacements = sceneDescription.externalPlacements.map(
|
|
40
47
|
(placement) => {
|
|
@@ -52,10 +59,18 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
changed = true
|
|
62
|
+
const preservesSourceOriginRepair =
|
|
63
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#isRepeatedOwnedDrilledPadAnchor(
|
|
64
|
+
placement,
|
|
65
|
+
component,
|
|
66
|
+
pads,
|
|
67
|
+
repeatedPlacementKeys
|
|
68
|
+
)
|
|
55
69
|
return AltiumScene3dAuthoredBodyAnchorAdapter.#markPlacement(
|
|
56
70
|
placement,
|
|
57
71
|
component,
|
|
58
|
-
sceneDescription?.board
|
|
72
|
+
sceneDescription?.board,
|
|
73
|
+
{ preservesSourceOriginRepair }
|
|
59
74
|
)
|
|
60
75
|
}
|
|
61
76
|
)
|
|
@@ -84,6 +99,55 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
84
99
|
)
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Finds repeated external-model identities within one built scene.
|
|
104
|
+
* @param {object[]} placements Scene external placements.
|
|
105
|
+
* @returns {Set<string>}
|
|
106
|
+
*/
|
|
107
|
+
static #repeatedPlacementKeys(placements) {
|
|
108
|
+
const counts = new Map()
|
|
109
|
+
const placementList = Array.isArray(placements) ? placements : []
|
|
110
|
+
|
|
111
|
+
placementList.forEach((placement) => {
|
|
112
|
+
const key =
|
|
113
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#placementIdentityKey(
|
|
114
|
+
placement
|
|
115
|
+
)
|
|
116
|
+
if (!key) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
counts.set(key, Number(counts.get(key) || 0) + 1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return new Set(
|
|
124
|
+
[...counts.entries()]
|
|
125
|
+
.filter(([, count]) => count > 1)
|
|
126
|
+
.map(([key]) => key)
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds a stable model-placement identity for repeated body detection.
|
|
132
|
+
* @param {object} placement External placement.
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
static #placementIdentityKey(placement) {
|
|
136
|
+
const designator = String(placement?.designator || '').trim()
|
|
137
|
+
const model = placement?.externalModel || {}
|
|
138
|
+
const modelParts = [
|
|
139
|
+
model?.origin,
|
|
140
|
+
model?.sourceStream,
|
|
141
|
+
model?.relativePath,
|
|
142
|
+
model?.name,
|
|
143
|
+
model?.format
|
|
144
|
+
].map((value) => String(value || '').trim())
|
|
145
|
+
|
|
146
|
+
return designator && modelParts.some(Boolean)
|
|
147
|
+
? [designator, ...modelParts].join('::')
|
|
148
|
+
: ''
|
|
149
|
+
}
|
|
150
|
+
|
|
87
151
|
/**
|
|
88
152
|
* Checks whether one placement should bypass runtime pad-fallback
|
|
89
153
|
* recentering.
|
|
@@ -97,6 +161,7 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
97
161
|
!component ||
|
|
98
162
|
String(placement?.projection?.source || '').toLowerCase() !==
|
|
99
163
|
'pad-fallback' ||
|
|
164
|
+
placement?.projection?.preservePadFallbackCentering ||
|
|
100
165
|
!placement?.positionMil ||
|
|
101
166
|
!placement?.bodyPositionMil
|
|
102
167
|
) {
|
|
@@ -206,33 +271,189 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
206
271
|
.join(' ')
|
|
207
272
|
}
|
|
208
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Checks whether one placement is one member of a repeated drilled-pad
|
|
276
|
+
* body set whose source-origin repair must remain enabled.
|
|
277
|
+
* @param {object} placement External placement.
|
|
278
|
+
* @param {object | undefined} component Matched scene component.
|
|
279
|
+
* @param {object[]} pads Scene detail pads.
|
|
280
|
+
* @param {Set<string>} repeatedPlacementKeys Repeated placement keys.
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
static #isRepeatedOwnedDrilledPadAnchor(
|
|
284
|
+
placement,
|
|
285
|
+
component,
|
|
286
|
+
pads,
|
|
287
|
+
repeatedPlacementKeys
|
|
288
|
+
) {
|
|
289
|
+
const key =
|
|
290
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#placementIdentityKey(
|
|
291
|
+
placement
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
repeatedPlacementKeys.has(key) &&
|
|
296
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#isOwnedDrilledPadAnchor(
|
|
297
|
+
placement,
|
|
298
|
+
component,
|
|
299
|
+
pads
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Checks whether the body anchor sits inside a drilled pad owned by the
|
|
306
|
+
* resolved component.
|
|
307
|
+
* @param {object} placement External placement.
|
|
308
|
+
* @param {object | undefined} component Matched scene component.
|
|
309
|
+
* @param {object[]} pads Scene detail pads.
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
static #isOwnedDrilledPadAnchor(placement, component, pads) {
|
|
313
|
+
const componentIndex = Number(component?.componentIndex)
|
|
314
|
+
if (!Number.isFinite(componentIndex)) {
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const bodyPosition = placement?.bodyPositionMil
|
|
319
|
+
if (
|
|
320
|
+
!AltiumScene3dAuthoredBodyAnchorAdapter.#hasFinitePoint(
|
|
321
|
+
bodyPosition
|
|
322
|
+
)
|
|
323
|
+
) {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return (Array.isArray(pads) ? pads : []).some(
|
|
328
|
+
(pad) =>
|
|
329
|
+
Number(pad?.componentIndex) === componentIndex &&
|
|
330
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#hasDrilledPadOpening(
|
|
331
|
+
pad
|
|
332
|
+
) &&
|
|
333
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#padContainsPoint(
|
|
334
|
+
pad,
|
|
335
|
+
bodyPosition
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Checks whether a pad contains a drilled or slotted board opening.
|
|
342
|
+
* @param {object} pad Scene detail pad.
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
static #hasDrilledPadOpening(pad) {
|
|
346
|
+
const holeGeometry = pad?.holeGeometry || {}
|
|
347
|
+
|
|
348
|
+
return [
|
|
349
|
+
pad?.holeDiameter,
|
|
350
|
+
pad?.drillDiameter,
|
|
351
|
+
pad?.holeSize,
|
|
352
|
+
pad?.holeSlotLength,
|
|
353
|
+
pad?.slotLength,
|
|
354
|
+
holeGeometry?.diameter,
|
|
355
|
+
holeGeometry?.length,
|
|
356
|
+
holeGeometry?.slotLength
|
|
357
|
+
].some((value) => Number(value || 0) > 0)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Checks whether one XY point falls inside the effective pad anchor span.
|
|
362
|
+
* @param {object} pad Scene detail pad.
|
|
363
|
+
* @param {{ x?: number, y?: number }} point Board-space point.
|
|
364
|
+
* @returns {boolean}
|
|
365
|
+
*/
|
|
366
|
+
static #padContainsPoint(pad, point) {
|
|
367
|
+
if (!AltiumScene3dAuthoredBodyAnchorAdapter.#hasFinitePoint(pad)) {
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const radius =
|
|
372
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#padAnchorRadiusMil(pad)
|
|
373
|
+
return (
|
|
374
|
+
radius > 0 &&
|
|
375
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#distance(pad, point) <=
|
|
376
|
+
radius +
|
|
377
|
+
AltiumScene3dAuthoredBodyAnchorAdapter
|
|
378
|
+
.#BODY_ANCHOR_TOLERANCE_MIL
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Resolves the effective XY radius around a drilled pad center.
|
|
384
|
+
* @param {object} pad Scene detail pad.
|
|
385
|
+
* @returns {number}
|
|
386
|
+
*/
|
|
387
|
+
static #padAnchorRadiusMil(pad) {
|
|
388
|
+
const holeGeometry = pad?.holeGeometry || {}
|
|
389
|
+
const diameter = Math.max(
|
|
390
|
+
Number(pad?.sizeTopX || 0),
|
|
391
|
+
Number(pad?.sizeTopY || 0),
|
|
392
|
+
Number(pad?.sizeMidX || 0),
|
|
393
|
+
Number(pad?.sizeMidY || 0),
|
|
394
|
+
Number(pad?.sizeBottomX || 0),
|
|
395
|
+
Number(pad?.sizeBottomY || 0),
|
|
396
|
+
Number(pad?.holeDiameter || 0),
|
|
397
|
+
Number(pad?.drillDiameter || 0),
|
|
398
|
+
Number(pad?.holeSize || 0),
|
|
399
|
+
Number(pad?.holeSlotLength || 0),
|
|
400
|
+
Number(pad?.slotLength || 0),
|
|
401
|
+
Number(holeGeometry?.diameter || 0),
|
|
402
|
+
Number(holeGeometry?.length || 0),
|
|
403
|
+
Number(holeGeometry?.slotLength || 0)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
|
|
407
|
+
}
|
|
408
|
+
|
|
209
409
|
/**
|
|
210
410
|
* Marks one placement as authored-anchor based.
|
|
211
411
|
* @param {object} placement External placement.
|
|
212
412
|
* @param {object} component Matched scene component.
|
|
213
413
|
* @param {object | undefined} board Scene board.
|
|
414
|
+
* @param {{ preservesSourceOriginRepair?: boolean }} [options] Marking options.
|
|
214
415
|
* @returns {object}
|
|
215
416
|
*/
|
|
216
|
-
static #markPlacement(placement, component, board) {
|
|
417
|
+
static #markPlacement(placement, component, board, options = {}) {
|
|
418
|
+
const reason = options.preservesSourceOriginRepair
|
|
419
|
+
? 'Altium repeated component body is anchored in an owned drilled pad, so the runtime preserves the body anchor while allowing embedded source-origin repair.'
|
|
420
|
+
: 'Altium component body uses an authored model-origin anchor offset from the owner footprint.'
|
|
421
|
+
|
|
217
422
|
return {
|
|
218
423
|
...placement,
|
|
219
424
|
projection: {
|
|
220
425
|
...(placement.projection || {}),
|
|
221
426
|
source: AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_SOURCE,
|
|
222
|
-
reason
|
|
427
|
+
reason
|
|
223
428
|
},
|
|
224
|
-
modelTransform:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
429
|
+
modelTransform: options.preservesSourceOriginRepair
|
|
430
|
+
? AltiumScene3dAuthoredBodyAnchorAdapter.#withoutOwnerAnchorOffset(
|
|
431
|
+
placement.modelTransform
|
|
432
|
+
)
|
|
433
|
+
: {
|
|
434
|
+
...(placement.modelTransform || {}),
|
|
435
|
+
ownerAnchorOffsetMil:
|
|
436
|
+
AltiumScene3dAuthoredBodyAnchorAdapter.#ownerAnchorOffset(
|
|
437
|
+
placement,
|
|
438
|
+
component,
|
|
439
|
+
board
|
|
440
|
+
)
|
|
441
|
+
}
|
|
233
442
|
}
|
|
234
443
|
}
|
|
235
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Removes owner-anchor metadata while preserving renderable transforms.
|
|
447
|
+
* @param {object | null | undefined} modelTransform Placement transform.
|
|
448
|
+
* @returns {object}
|
|
449
|
+
*/
|
|
450
|
+
static #withoutOwnerAnchorOffset(modelTransform) {
|
|
451
|
+
const { ownerAnchorOffsetMil, ...renderTransform } =
|
|
452
|
+
modelTransform || {}
|
|
453
|
+
|
|
454
|
+
return renderTransform
|
|
455
|
+
}
|
|
456
|
+
|
|
236
457
|
/**
|
|
237
458
|
* Resolves the source body offset from its owner footprint anchor.
|
|
238
459
|
* @param {object} placement External placement.
|
|
@@ -313,6 +534,18 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
|
|
|
313
534
|
}
|
|
314
535
|
}
|
|
315
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Checks whether a value has finite XY coordinates.
|
|
539
|
+
* @param {object | undefined} point Source point.
|
|
540
|
+
* @returns {boolean}
|
|
541
|
+
*/
|
|
542
|
+
static #hasFinitePoint(point) {
|
|
543
|
+
return (
|
|
544
|
+
Number.isFinite(Number(point?.x)) &&
|
|
545
|
+
Number.isFinite(Number(point?.y))
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
316
549
|
/**
|
|
317
550
|
* Measures XY distance between two points.
|
|
318
551
|
* @param {{ x: number, y: number }} first First point.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides when explicit Altium connector-body yaw should win over footprint
|
|
3
|
+
* rotation for off-anchor 3D bodies.
|
|
4
|
+
*/
|
|
5
|
+
export class AltiumScene3dAuthoredConnectorYawPolicy {
|
|
6
|
+
static #CONNECTOR_OWNER_PATTERN =
|
|
7
|
+
/(?:pin\s*header|pinheader|header|connector|socket|fpc|flex|jtag)/i
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks whether an offset connector body should keep Altium's authored
|
|
11
|
+
* body yaw instead of adopting the footprint yaw.
|
|
12
|
+
* @param {{ placement?: object, component?: object | null, pads?: object[], ownerOffsetToleranceMil?: number }} context Placement context.
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
static shouldPreserve(context) {
|
|
16
|
+
const placement = context?.placement
|
|
17
|
+
const component = context?.component
|
|
18
|
+
const ownerOffsetToleranceMil = Number(
|
|
19
|
+
context?.ownerOffsetToleranceMil || 0
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
!component ||
|
|
24
|
+
!AltiumScene3dAuthoredConnectorYawPolicy.#supportsProjection(
|
|
25
|
+
placement
|
|
26
|
+
) ||
|
|
27
|
+
AltiumScene3dAuthoredConnectorYawPolicy.#distanceToBody(
|
|
28
|
+
placement,
|
|
29
|
+
component
|
|
30
|
+
) <= ownerOffsetToleranceMil ||
|
|
31
|
+
!AltiumScene3dAuthoredConnectorYawPolicy.#hasConnectorOwnerIdentity(
|
|
32
|
+
component
|
|
33
|
+
)
|
|
34
|
+
) {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return AltiumScene3dAuthoredConnectorYawPolicy.#hasSingleRowPadGeometry(
|
|
39
|
+
component,
|
|
40
|
+
context?.pads
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Checks whether one projection can carry an authored off-center connector
|
|
46
|
+
* source yaw.
|
|
47
|
+
* @param {object | null | undefined} placement External placement.
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
static #supportsProjection(placement) {
|
|
51
|
+
const source = String(placement?.projection?.source || '').toLowerCase()
|
|
52
|
+
|
|
53
|
+
return source === 'pad-fallback' || source === 'model-bounds'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks whether component metadata describes connector/header hardware.
|
|
58
|
+
* @param {object} component PCB component.
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
static #hasConnectorOwnerIdentity(component) {
|
|
62
|
+
return AltiumScene3dAuthoredConnectorYawPolicy.#CONNECTOR_OWNER_PATTERN.test(
|
|
63
|
+
[
|
|
64
|
+
component?.designator,
|
|
65
|
+
component?.pattern,
|
|
66
|
+
component?.source,
|
|
67
|
+
component?.description,
|
|
68
|
+
...Object.values(component?.parameters || {})
|
|
69
|
+
]
|
|
70
|
+
.map((value) => String(value || ''))
|
|
71
|
+
.join(' ')
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Checks whether owned pads form one long connector row.
|
|
77
|
+
* @param {object} component PCB component.
|
|
78
|
+
* @param {object[] | undefined} pads Source PCB pads.
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
static #hasSingleRowPadGeometry(component, pads) {
|
|
82
|
+
const measurablePads =
|
|
83
|
+
AltiumScene3dAuthoredConnectorYawPolicy.#componentPads(
|
|
84
|
+
component,
|
|
85
|
+
pads
|
|
86
|
+
).filter((pad) =>
|
|
87
|
+
AltiumScene3dAuthoredConnectorYawPolicy.#isMeasurablePad(pad)
|
|
88
|
+
)
|
|
89
|
+
if (measurablePads.length < 3) {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const xs = measurablePads.map((pad) => Number(pad?.x || 0))
|
|
94
|
+
const ys = measurablePads.map((pad) => Number(pad?.y || 0))
|
|
95
|
+
const spreadX = Math.max(...xs) - Math.min(...xs)
|
|
96
|
+
const spreadY = Math.max(...ys) - Math.min(...ys)
|
|
97
|
+
const majorSpread = Math.max(spreadX, spreadY)
|
|
98
|
+
const minorSpread = Math.min(spreadX, spreadY)
|
|
99
|
+
const maxPadSpan = Math.max(
|
|
100
|
+
...measurablePads.map((pad) =>
|
|
101
|
+
Math.max(
|
|
102
|
+
Number(pad?.sizeTopX || 0),
|
|
103
|
+
Number(pad?.sizeTopY || 0),
|
|
104
|
+
Number(pad?.sizeMidX || 0),
|
|
105
|
+
Number(pad?.sizeMidY || 0),
|
|
106
|
+
Number(pad?.sizeBottomX || 0),
|
|
107
|
+
Number(pad?.sizeBottomY || 0)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
majorSpread >= Math.max(100, maxPadSpan * 3) &&
|
|
114
|
+
minorSpread <= Math.max(10, maxPadSpan * 1.25)
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns pads owned by one component index.
|
|
120
|
+
* @param {object} component Owning component.
|
|
121
|
+
* @param {object[] | undefined} pads Source PCB pads.
|
|
122
|
+
* @returns {object[]}
|
|
123
|
+
*/
|
|
124
|
+
static #componentPads(component, pads) {
|
|
125
|
+
const componentIndex = Number(component?.componentIndex)
|
|
126
|
+
if (!Number.isFinite(componentIndex)) {
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (Array.isArray(pads) ? pads : []).filter(
|
|
131
|
+
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks whether one pad has finite coordinates and non-zero dimensions.
|
|
137
|
+
* @param {object} pad Source PCB pad.
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
static #isMeasurablePad(pad) {
|
|
141
|
+
const width = Math.max(
|
|
142
|
+
Number(pad?.sizeTopX || 0),
|
|
143
|
+
Number(pad?.sizeMidX || 0),
|
|
144
|
+
Number(pad?.sizeBottomX || 0)
|
|
145
|
+
)
|
|
146
|
+
const depth = Math.max(
|
|
147
|
+
Number(pad?.sizeTopY || 0),
|
|
148
|
+
Number(pad?.sizeMidY || 0),
|
|
149
|
+
Number(pad?.sizeBottomY || 0)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
Number.isFinite(Number(pad?.x)) &&
|
|
154
|
+
Number.isFinite(Number(pad?.y)) &&
|
|
155
|
+
width > 0 &&
|
|
156
|
+
depth > 0
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Measures planar distance from placement body anchor to component origin.
|
|
162
|
+
* @param {object} placement External model placement.
|
|
163
|
+
* @param {object} component PCB component.
|
|
164
|
+
* @returns {number}
|
|
165
|
+
*/
|
|
166
|
+
static #distanceToBody(placement, component) {
|
|
167
|
+
const dx =
|
|
168
|
+
Number(placement?.bodyPositionMil?.x || 0) -
|
|
169
|
+
Number(component?.x || 0)
|
|
170
|
+
const dy =
|
|
171
|
+
Number(placement?.bodyPositionMil?.y || 0) -
|
|
172
|
+
Number(component?.y || 0)
|
|
173
|
+
|
|
174
|
+
return Math.hypot(dx, dy)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const BOTTOM_SOURCE_HALF_TURN_PACKAGE_PATTERN =
|
|
2
|
+
/(?:^|[^a-z0-9])(?:[a-z0-9]*qfn[a-z0-9]*|[a-z0-9]*dfn[a-z0-9]*)(?:$|[^a-z0-9])/i
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves whether a bottom-side source-model half-turn is part of the
|
|
6
|
+
* package's contact-side frame and must survive mount-side normalization.
|
|
7
|
+
*/
|
|
8
|
+
export class AltiumScene3dBottomSourceHalfTurnPolicy {
|
|
9
|
+
/**
|
|
10
|
+
* Checks whether a bottom-side model X half-turn should be preserved.
|
|
11
|
+
* @param {{ component?: object | null, componentBody?: object | null, placement?: object | null, modelTransform?: object | null }} context Placement context.
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
static shouldPreserve(context = {}) {
|
|
15
|
+
if (
|
|
16
|
+
!AltiumScene3dBottomSourceHalfTurnPolicy.#hasSourceHalfTurn(context)
|
|
17
|
+
) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return BOTTOM_SOURCE_HALF_TURN_PACKAGE_PATTERN.test(
|
|
22
|
+
AltiumScene3dBottomSourceHalfTurnPolicy.#packageIdentityText(
|
|
23
|
+
context
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether source data carries an X-axis half-turn.
|
|
30
|
+
* @param {{ componentBody?: object | null, modelTransform?: object | null }} context Placement context.
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
static #hasSourceHalfTurn(context) {
|
|
34
|
+
const sourceRotation = context?.componentBody?.modelRotationDeg
|
|
35
|
+
const renderRotation = context?.modelTransform?.rotationDeg
|
|
36
|
+
const sourceX =
|
|
37
|
+
sourceRotation?.x !== undefined
|
|
38
|
+
? sourceRotation.x
|
|
39
|
+
: renderRotation?.x
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
AltiumScene3dBottomSourceHalfTurnPolicy.#normalizeAngle(sourceX) ===
|
|
43
|
+
180
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds identity text from the component, source body, and placement.
|
|
49
|
+
* @param {{ component?: object | null, componentBody?: object | null, placement?: object | null }} context Placement context.
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
static #packageIdentityText(context) {
|
|
53
|
+
const component = context?.component || {}
|
|
54
|
+
const componentBody = context?.componentBody || {}
|
|
55
|
+
const placement = context?.placement || {}
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
component?.pattern,
|
|
59
|
+
component?.source,
|
|
60
|
+
component?.description,
|
|
61
|
+
...AltiumScene3dBottomSourceHalfTurnPolicy.#recordValues(
|
|
62
|
+
component?.parameters
|
|
63
|
+
),
|
|
64
|
+
componentBody?.identifier,
|
|
65
|
+
componentBody?.modelId,
|
|
66
|
+
componentBody?.name,
|
|
67
|
+
placement?.externalModel?.name
|
|
68
|
+
]
|
|
69
|
+
.map((value) => String(value || ''))
|
|
70
|
+
.join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves stringable values from one optional metadata record.
|
|
75
|
+
* @param {Record<string, unknown> | null | undefined} record Source record.
|
|
76
|
+
* @returns {unknown[]}
|
|
77
|
+
*/
|
|
78
|
+
static #recordValues(record) {
|
|
79
|
+
return record && typeof record === 'object' ? Object.values(record) : []
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalizes one angle into the positive 0-359 degree range.
|
|
84
|
+
* @param {unknown} angle Source angle.
|
|
85
|
+
* @returns {number}
|
|
86
|
+
*/
|
|
87
|
+
static #normalizeAngle(angle) {
|
|
88
|
+
const numericAngle = Number(angle || 0)
|
|
89
|
+
const normalized = numericAngle % 360
|
|
90
|
+
|
|
91
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
92
|
+
}
|
|
93
|
+
}
|