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
@@ -2,6 +2,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { AltiumGeneratedLibraryRecordBuilder } from './AltiumGeneratedLibraryRecordBuilder.mjs'
6
+
5
7
  /**
6
8
  * Builds deterministic textual and byte records for generated Altium libraries.
7
9
  */
@@ -22,6 +24,17 @@ export class AltiumLibraryRecordBuilder {
22
24
  })
23
25
  }
24
26
 
27
+ /**
28
+ * Builds a full schematic component data record stream.
29
+ * @param {object} bundle Normalized component bundle.
30
+ * @returns {string}
31
+ */
32
+ static buildSchematicComponentDataRecord(bundle) {
33
+ return AltiumGeneratedLibraryRecordBuilder.buildSchematicComponentData(
34
+ bundle
35
+ )
36
+ }
37
+
25
38
  /**
26
39
  * Builds a PCB footprint record.
27
40
  * @param {object} bundle Normalized component bundle.
@@ -35,10 +48,24 @@ export class AltiumLibraryRecordBuilder {
35
48
  DisplayName: bundle.name,
36
49
  PadCount: String(bundle.footprint.pads.length),
37
50
  TrackCount: String(bundle.footprint.tracks.length),
51
+ ArcCount: String(bundle.footprint.arcs.length),
52
+ FillCount: String(bundle.footprint.fills.length),
53
+ TextCount: String(bundle.footprint.texts.length),
38
54
  ModelCount: String(bundle.models.length)
39
55
  })
40
56
  }
41
57
 
58
+ /**
59
+ * Counts generated footprint primitives.
60
+ * @param {object} bundle Normalized bundle.
61
+ * @returns {number}
62
+ */
63
+ static footprintPrimitiveCount(bundle) {
64
+ return AltiumGeneratedLibraryRecordBuilder.footprintPrimitiveCount(
65
+ bundle.footprint
66
+ )
67
+ }
68
+
42
69
  /**
43
70
  * Builds a PcbLib Library/Data stream.
44
71
  * @param {object[]} bundles Normalized component bundles.
@@ -113,7 +140,10 @@ export class AltiumLibraryRecordBuilder {
113
140
  */
114
141
  static buildFootprintData(bundle) {
115
142
  return AltiumLibraryRecordBuilder.concatBytes([
116
- AltiumLibraryRecordBuilder.createStringBlock(bundle.footprint.name)
143
+ AltiumLibraryRecordBuilder.createStringBlock(bundle.footprint.name),
144
+ ...AltiumGeneratedLibraryRecordBuilder.buildFootprintPrimitiveRecords(
145
+ bundle.footprint
146
+ )
117
147
  ])
118
148
  }
119
149
 
@@ -133,7 +163,7 @@ export class AltiumLibraryRecordBuilder {
133
163
 
134
164
  /**
135
165
  * Builds model metadata entries.
136
- * @param {{ model: object, id: string, checksum: number }[]} models Model rows.
166
+ * @param {{ bundle: object, model: object, id: string, checksum: number }[]} models Model rows.
137
167
  * @returns {Uint8Array}
138
168
  */
139
169
  static buildModelsData(models) {
@@ -141,7 +171,12 @@ export class AltiumLibraryRecordBuilder {
141
171
  models.map((row) =>
142
172
  AltiumLibraryRecordBuilder.createLengthPrefixedAscii(
143
173
  AltiumLibraryRecordBuilder.#pipeRecord({
174
+ EMBED: 'TRUE',
175
+ MODELSOURCE: 'Undefined',
144
176
  ID: row.id,
177
+ ...AltiumLibraryRecordBuilder.#modelMetadataTransform(
178
+ row
179
+ ),
145
180
  NAME: row.model.name,
146
181
  CHECKSUM: String(row.checksum),
147
182
  FORMAT: row.model.format
@@ -151,6 +186,23 @@ export class AltiumLibraryRecordBuilder {
151
186
  )
152
187
  }
153
188
 
189
+ /**
190
+ * Builds component-body placement records for embedded model rows.
191
+ * @param {{ bundle: object, model: object, id: string, checksum: number }[]} models Model rows.
192
+ * @returns {Uint8Array}
193
+ */
194
+ static buildComponentBodiesData(models) {
195
+ return AltiumLibraryRecordBuilder.concatBytes(
196
+ models.map((row) =>
197
+ new TextEncoder().encode(
198
+ AltiumLibraryRecordBuilder.#pipeRecord(
199
+ AltiumLibraryRecordBuilder.#componentBodyFields(row)
200
+ ) + '\u0000'
201
+ )
202
+ )
203
+ )
204
+ }
205
+
154
206
  /**
155
207
  * Creates a little-endian count header.
156
208
  * @param {number} count Count value.
@@ -249,6 +301,238 @@ export class AltiumLibraryRecordBuilder {
249
301
  )
250
302
  }
251
303
 
304
+ /**
305
+ * Builds model metadata transform fields.
306
+ * @param {{ bundle: object, model: object }} row Model row.
307
+ * @returns {Record<string, string>}
308
+ */
309
+ static #modelMetadataTransform(row) {
310
+ const rotation = AltiumLibraryRecordBuilder.#modelRotation(
311
+ row.model,
312
+ AltiumLibraryRecordBuilder.#rowComponent(row)
313
+ )
314
+
315
+ return {
316
+ ROTX: AltiumLibraryRecordBuilder.#numberText(rotation.x),
317
+ ROTY: AltiumLibraryRecordBuilder.#numberText(rotation.y),
318
+ ROTZ: AltiumLibraryRecordBuilder.#numberText(rotation.z),
319
+ DZ: AltiumLibraryRecordBuilder.#milText(
320
+ AltiumLibraryRecordBuilder.#modelDzMil(row.model)
321
+ )
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Builds one component-body placement record.
327
+ * @param {{ bundle: object, model: object, id: string, checksum: number }} row Model row.
328
+ * @returns {Record<string, string>}
329
+ */
330
+ static #componentBodyFields(row) {
331
+ const offset = AltiumLibraryRecordBuilder.#modelOffsetMil(row.model)
332
+ const rotation = AltiumLibraryRecordBuilder.#modelRotation(
333
+ row.model,
334
+ AltiumLibraryRecordBuilder.#rowComponent(row)
335
+ )
336
+
337
+ return {
338
+ V7_LAYER: 'MECHANICAL1',
339
+ KIND: '0',
340
+ SUBPOLYINDEX: '-1',
341
+ UNIONINDEX: '0',
342
+ ISSHAPEBASED: 'FALSE',
343
+ STANDOFFHEIGHT: '0mil',
344
+ OVERALLHEIGHT: '0mil',
345
+ IDENTIFIER: AltiumLibraryRecordBuilder.#identifierBytes(
346
+ row.bundle?.footprint?.name || row.model?.name || row.id
347
+ ),
348
+ MODELID: row.id,
349
+ 'MODEL.CHECKSUM': String(row.checksum),
350
+ 'MODEL.EMBED': 'TRUE',
351
+ 'MODEL.NAME': row.model.name,
352
+ 'MODEL.2D.X': AltiumLibraryRecordBuilder.#milText(offset.x),
353
+ 'MODEL.2D.Y': AltiumLibraryRecordBuilder.#milText(offset.y),
354
+ 'MODEL.2D.ROTATION': '0',
355
+ 'MODEL.3D.ROTX': AltiumLibraryRecordBuilder.#numberText(rotation.x),
356
+ 'MODEL.3D.ROTY': AltiumLibraryRecordBuilder.#numberText(rotation.y),
357
+ 'MODEL.3D.ROTZ': AltiumLibraryRecordBuilder.#numberText(rotation.z),
358
+ 'MODEL.3D.DZ': AltiumLibraryRecordBuilder.#milText(
359
+ AltiumLibraryRecordBuilder.#modelDzMil(row.model)
360
+ ),
361
+ 'MODEL.MODELSOURCE': 'Undefined'
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Resolves the source component attached to a model row.
367
+ * @param {{ bundle?: object }} row Model row.
368
+ * @returns {object}
369
+ */
370
+ static #rowComponent(row) {
371
+ return (
372
+ row?.bundle?.footprint?.component ||
373
+ row?.bundle?.footprint?.raw?.component ||
374
+ {}
375
+ )
376
+ }
377
+
378
+ /**
379
+ * Resolves one model transform object.
380
+ * @param {object} model Model asset.
381
+ * @returns {object}
382
+ */
383
+ static #modelTransform(model) {
384
+ return model?.transform || model?.raw?.transform || {}
385
+ }
386
+
387
+ /**
388
+ * Resolves model offset in mils.
389
+ * @param {object} model Model asset.
390
+ * @returns {{ x: number, y: number }}
391
+ */
392
+ static #modelOffsetMil(model) {
393
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
394
+ const offsetMil = transform.offsetMil || {}
395
+
396
+ return {
397
+ x: AltiumLibraryRecordBuilder.#round(
398
+ AltiumLibraryRecordBuilder.#number(
399
+ offsetMil.x ?? transform.dxMil,
400
+ 0
401
+ )
402
+ ),
403
+ y: AltiumLibraryRecordBuilder.#round(
404
+ AltiumLibraryRecordBuilder.#number(
405
+ offsetMil.y ?? transform.dyMil,
406
+ 0
407
+ )
408
+ )
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Resolves one model Z offset in mils.
414
+ * @param {object} model Model asset.
415
+ * @returns {number}
416
+ */
417
+ static #modelDzMil(model) {
418
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
419
+ const offsetMil = transform.offsetMil || {}
420
+
421
+ return AltiumLibraryRecordBuilder.#round(
422
+ AltiumLibraryRecordBuilder.#number(
423
+ offsetMil.z ?? transform.dzMil,
424
+ 0
425
+ )
426
+ )
427
+ }
428
+
429
+ /**
430
+ * Resolves model 3D rotation in footprint-local degrees.
431
+ * @param {object} model Model asset.
432
+ * @param {object} component Selected footprint component origin.
433
+ * @returns {{ x: number, y: number, z: number }}
434
+ */
435
+ static #modelRotation(model, component) {
436
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
437
+ const rotation = transform.rotationDeg || {}
438
+ const generatedRotation =
439
+ AltiumLibraryRecordBuilder.#generatedFrameRotation(model, component)
440
+
441
+ return {
442
+ x: AltiumLibraryRecordBuilder.#round(
443
+ AltiumLibraryRecordBuilder.#number(rotation.x, 0) +
444
+ generatedRotation.x
445
+ ),
446
+ y: AltiumLibraryRecordBuilder.#round(
447
+ AltiumLibraryRecordBuilder.#number(rotation.y, 0) +
448
+ generatedRotation.y
449
+ ),
450
+ z: AltiumLibraryRecordBuilder.#round(
451
+ AltiumLibraryRecordBuilder.#number(rotation.z, 0) +
452
+ generatedRotation.z
453
+ )
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Resolves correction for generated stitched STEP files.
459
+ * @param {object} model Model asset.
460
+ * @param {object} component Selected footprint component origin.
461
+ * @returns {{ x: number, y: number, z: number }}
462
+ */
463
+ static #generatedFrameRotation(model, component) {
464
+ if (model?.generated !== true && model?.raw?.generated !== true) {
465
+ return { x: 0, y: 0, z: 0 }
466
+ }
467
+
468
+ return {
469
+ x: -90,
470
+ y: 0,
471
+ z: AltiumLibraryRecordBuilder.#normalizeAngle(
472
+ -AltiumLibraryRecordBuilder.#number(component?.rotation, 0)
473
+ )
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Encodes one Altium identifier as comma-separated character codes.
479
+ * @param {string} text Source text.
480
+ * @returns {string}
481
+ */
482
+ static #identifierBytes(text) {
483
+ return [...String(text || '')]
484
+ .map((character) => character.charCodeAt(0))
485
+ .join(',')
486
+ }
487
+
488
+ /**
489
+ * Formats a model distance in mils.
490
+ * @param {number} value Value in mils.
491
+ * @returns {string}
492
+ */
493
+ static #milText(value) {
494
+ return AltiumLibraryRecordBuilder.#numberText(value) + 'mil'
495
+ }
496
+
497
+ /**
498
+ * Formats one numeric record field.
499
+ * @param {number} value Numeric value.
500
+ * @returns {string}
501
+ */
502
+ static #numberText(value) {
503
+ return String(AltiumLibraryRecordBuilder.#round(value))
504
+ }
505
+
506
+ /**
507
+ * Reads a finite number with fallback.
508
+ * @param {unknown} value Candidate value.
509
+ * @param {number} fallback Fallback value.
510
+ * @returns {number}
511
+ */
512
+ static #number(value, fallback) {
513
+ const number = Number(value)
514
+ return Number.isFinite(number) ? number : fallback
515
+ }
516
+
517
+ /**
518
+ * Rounds generated numeric fields.
519
+ * @param {number} value Numeric value.
520
+ * @returns {number}
521
+ */
522
+ static #round(value) {
523
+ const rounded = Number(Number(value || 0).toFixed(6))
524
+ return Object.is(rounded, -0) ? 0 : rounded
525
+ }
526
+
527
+ /**
528
+ * Normalizes a degree angle to the 0-360 range.
529
+ * @param {number} angle Angle in degrees.
530
+ * @returns {number}
531
+ */
532
+ static #normalizeAngle(angle) {
533
+ return ((angle % 360) + 360) % 360
534
+ }
535
+
252
536
  /**
253
537
  * Builds one pipe-delimited record.
254
538
  * @param {Record<string, string>} fields Record fields.
@@ -42,7 +42,9 @@ export class AltiumPcbLibExporter {
42
42
  )
43
43
  streams.set(
44
44
  storageName + '/Header',
45
- AltiumLibraryRecordBuilder.createCountHeader(0)
45
+ AltiumLibraryRecordBuilder.createCountHeader(
46
+ AltiumLibraryRecordBuilder.footprintPrimitiveCount(bundle)
47
+ )
46
48
  )
47
49
  streams.set(
48
50
  storageName + '/Parameters',
@@ -65,6 +67,10 @@ export class AltiumPcbLibExporter {
65
67
  'Models/Data',
66
68
  AltiumLibraryRecordBuilder.buildModelsData(modelRows)
67
69
  )
70
+ streams.set(
71
+ 'ComponentBodies6/Data',
72
+ AltiumLibraryRecordBuilder.buildComponentBodiesData(modelRows)
73
+ )
68
74
  modelRows.forEach((row, index) => {
69
75
  streams.set('Models/' + index, row.model.bytes)
70
76
  })
@@ -76,11 +82,12 @@ export class AltiumPcbLibExporter {
76
82
  /**
77
83
  * Collects model rows with deterministic generated ids.
78
84
  * @param {object[]} bundles Normalized bundles.
79
- * @returns {{ model: object, id: string, checksum: number }[]}
85
+ * @returns {{ bundle: object, model: object, id: string, checksum: number }[]}
80
86
  */
81
87
  static #collectModels(bundles) {
82
88
  return bundles.flatMap((bundle, bundleIndex) =>
83
89
  bundle.models.map((model, modelIndex) => ({
90
+ bundle,
84
91
  model,
85
92
  id: 'model-' + bundleIndex + '-' + modelIndex,
86
93
  checksum: AltiumLibraryRecordBuilder.checksumBytes(model.bytes)
@@ -34,7 +34,7 @@ export class AltiumSchLibExporter {
34
34
  ) +
35
35
  '/Data',
36
36
  new TextEncoder().encode(
37
- AltiumLibraryRecordBuilder.buildSchematicComponentRecord(
37
+ AltiumLibraryRecordBuilder.buildSchematicComponentDataRecord(
38
38
  bundle
39
39
  )
40
40
  )
@@ -20,23 +20,128 @@ export class PcbComponentBodyPlacementNormalizer {
20
20
  const mirrorY = (value) =>
21
21
  Number(boardOutline?.minY || 0) + maxY - Number(value || 0)
22
22
 
23
- return componentBodies.map((componentBody) => ({
24
- ...componentBody,
25
- positionMil: {
26
- x: Number(componentBody.positionMil?.x || 0),
27
- y: mirrorY(componentBody.positionMil?.y || 0)
28
- },
29
- rotationDeg: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
30
- 360 - Number(componentBody.rotationDeg || 0)
31
- ),
32
- modelRotationDeg: {
33
- x: Number(componentBody.modelRotationDeg?.x || 0),
34
- y: Number(componentBody.modelRotationDeg?.y || 0),
35
- z: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
36
- 360 - Number(componentBody.modelRotationDeg?.z || 0)
37
- )
23
+ return componentBodies.map((componentBody) => {
24
+ const normalizedBody = {
25
+ ...componentBody,
26
+ positionMil: {
27
+ x: Number(componentBody.positionMil?.x || 0),
28
+ y: mirrorY(componentBody.positionMil?.y || 0)
29
+ },
30
+ rotationDeg:
31
+ PcbComponentBodyPlacementNormalizer.#normalizeAngle(
32
+ 360 - Number(componentBody.rotationDeg || 0)
33
+ ),
34
+ modelRotationDeg: {
35
+ x: Number(componentBody.modelRotationDeg?.x || 0),
36
+ y: Number(componentBody.modelRotationDeg?.y || 0),
37
+ z: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
38
+ 360 - Number(componentBody.modelRotationDeg?.z || 0)
39
+ )
40
+ }
38
41
  }
42
+ const staticGeometry =
43
+ PcbComponentBodyPlacementNormalizer.#normalizeStaticGeometry(
44
+ componentBody,
45
+ mirrorY
46
+ )
47
+
48
+ return staticGeometry
49
+ ? {
50
+ ...normalizedBody,
51
+ staticGeometry
52
+ }
53
+ : normalizedBody
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Mirrors source-coordinate static geometry into the viewer coordinate
59
+ * system.
60
+ * @param {{ positionMil?: { x?: number, y?: number }, staticGeometry?: object }} componentBody Component body.
61
+ * @param {(value: number | string | undefined) => number} mirrorY Source Y mirror.
62
+ * @returns {object | undefined}
63
+ */
64
+ static #normalizeStaticGeometry(componentBody, mirrorY) {
65
+ const staticGeometry = componentBody?.staticGeometry
66
+ if (!staticGeometry || typeof staticGeometry !== 'object') {
67
+ return staticGeometry
68
+ }
69
+
70
+ const vertices = Array.isArray(staticGeometry.verticesMil)
71
+ ? staticGeometry.verticesMil
72
+ : []
73
+ if (
74
+ !PcbComponentBodyPlacementNormalizer.#shouldMirrorStaticVertices(
75
+ componentBody,
76
+ vertices
77
+ )
78
+ ) {
79
+ return staticGeometry
80
+ }
81
+
82
+ return {
83
+ ...staticGeometry,
84
+ verticesMil: vertices.map((vertex) => ({
85
+ ...vertex,
86
+ x: Number(vertex?.x || 0),
87
+ y: mirrorY(vertex?.y || 0)
88
+ }))
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Checks whether polygon vertices share the body source-coordinate frame.
94
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
95
+ * @param {{ x?: number, y?: number }[]} vertices Static polygon vertices.
96
+ * @returns {boolean}
97
+ */
98
+ static #shouldMirrorStaticVertices(componentBody, vertices) {
99
+ if (vertices.length < 3) {
100
+ return false
101
+ }
102
+
103
+ const center =
104
+ PcbComponentBodyPlacementNormalizer.#polygonBoundsCenter(vertices)
105
+ const source = {
106
+ x: Number(componentBody?.positionMil?.x || 0),
107
+ y: Number(componentBody?.positionMil?.y || 0)
108
+ }
109
+
110
+ return (
111
+ PcbComponentBodyPlacementNormalizer.#distance(center, source) <= 5
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Resolves an axis-aligned polygon bounds center.
117
+ * @param {{ x?: number, y?: number }[]} vertices Static polygon vertices.
118
+ * @returns {{ x: number, y: number }}
119
+ */
120
+ static #polygonBoundsCenter(vertices) {
121
+ const points = vertices.map((vertex) => ({
122
+ x: Number(vertex?.x || 0),
123
+ y: Number(vertex?.y || 0)
39
124
  }))
125
+ const xs = points.map((point) => point.x)
126
+ const ys = points.map((point) => point.y)
127
+
128
+ return {
129
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
130
+ y: (Math.min(...ys) + Math.max(...ys)) / 2
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Measures XY distance between two points.
136
+ * @param {{ x?: number, y?: number }} first First point.
137
+ * @param {{ x?: number, y?: number }} second Second point.
138
+ * @returns {number}
139
+ */
140
+ static #distance(first, second) {
141
+ return Math.hypot(
142
+ Number(first?.x || 0) - Number(second?.x || 0),
143
+ Number(first?.y || 0) - Number(second?.y || 0)
144
+ )
40
145
  }
41
146
 
42
147
  /**
@@ -419,12 +419,23 @@ export class PcbEmbeddedModelExtractor {
419
419
  ? undefined
420
420
  : PcbEmbeddedModelExtractor.#decodeBodyColor(bodyColorRaw),
421
421
  bodyOpacity:
422
- bodyOpacity === null || !Number.isFinite(bodyOpacity)
423
- ? undefined
424
- : bodyOpacity
422
+ PcbEmbeddedModelExtractor.#normalizeBodyOpacity(bodyOpacity)
425
423
  })
426
424
  }
427
425
 
426
+ /**
427
+ * Normalizes native body opacity metadata to authored translucency only.
428
+ * @param {number | null} bodyOpacity Native body opacity value.
429
+ * @returns {number | undefined}
430
+ */
431
+ static #normalizeBodyOpacity(bodyOpacity) {
432
+ if (!Number.isFinite(bodyOpacity) || Number(bodyOpacity) <= 0) {
433
+ return undefined
434
+ }
435
+
436
+ return bodyOpacity
437
+ }
438
+
428
439
  /**
429
440
  * Maps a numeric body model type to a stable semantic label.
430
441
  * @param {number} modelType Numeric model type.
@@ -498,7 +509,14 @@ export class PcbEmbeddedModelExtractor {
498
509
  componentBody.dzMil
499
510
  ].join('\u0000')
500
511
 
501
- if (!uniqueBodies.has(key)) {
512
+ const existingBody = uniqueBodies.get(key)
513
+ if (
514
+ !existingBody ||
515
+ PcbEmbeddedModelExtractor.#shouldPreferComponentBody(
516
+ componentBody,
517
+ existingBody
518
+ )
519
+ ) {
502
520
  uniqueBodies.set(key, componentBody)
503
521
  }
504
522
  }
@@ -506,6 +524,36 @@ export class PcbEmbeddedModelExtractor {
506
524
  return [...uniqueBodies.values()]
507
525
  }
508
526
 
527
+ /**
528
+ * Checks whether one component-body row carries better static geometry.
529
+ * @param {{ staticGeometry?: object }} candidate Candidate body row.
530
+ * @param {{ staticGeometry?: object }} existing Existing body row.
531
+ * @returns {boolean}
532
+ */
533
+ static #shouldPreferComponentBody(candidate, existing) {
534
+ return (
535
+ PcbEmbeddedModelExtractor.#staticGeometryRank(candidate) >
536
+ PcbEmbeddedModelExtractor.#staticGeometryRank(existing)
537
+ )
538
+ }
539
+
540
+ /**
541
+ * Ranks static geometry completeness for duplicate body rows.
542
+ * @param {{ staticGeometry?: object }} componentBody Component body row.
543
+ * @returns {number}
544
+ */
545
+ static #staticGeometryRank(componentBody) {
546
+ if (componentBody?.staticGeometry?.status === 'complete') {
547
+ return 2
548
+ }
549
+
550
+ if (componentBody?.staticGeometry) {
551
+ return 1
552
+ }
553
+
554
+ return 0
555
+ }
556
+
509
557
  /**
510
558
  * Builds model metadata and payload integrity diagnostics.
511
559
  * @param {object[]} metadataRows Parsed model metadata rows.