pcb-scene3d-viewer 1.1.49 → 1.2.0

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 (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -2,13 +2,13 @@ import { PcbScene3dBoardEdgeCutoutBuilder } from './PcbScene3dBoardEdgeCutoutBui
2
2
  import { PcbScene3dBoardCutoutPathFactory } from './PcbScene3dBoardCutoutPathFactory.mjs'
3
3
  import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
4
4
  import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
5
+ import { PcbScene3dPlatedDrillSpecResolver } from './PcbScene3dPlatedDrillSpecResolver.mjs'
5
6
 
6
7
  /**
7
8
  * Builds the board solid profile, including drilled holes.
8
9
  */
9
10
  export class PcbScene3dBoardShapeFactory {
10
11
  static #CURVE_SEGMENTS = 8
11
- static #PAD_HOLE_SHAPE_SLOT = 2
12
12
  static #PLATED_WALL_MATERIAL_INDEX = 2
13
13
  static #EDGE_WALL_MATERIAL_INDEX = 1
14
14
  static #CONTOUR_SAMPLE_POINTS = 64
@@ -235,7 +235,9 @@ export class PcbScene3dBoardShapeFactory {
235
235
  const diameter = Number(normalizedSpec.diameter || 0)
236
236
  const slotLength = Number(normalizedSpec.slotLength || 0)
237
237
  const isCircular =
238
- diameter > 0 && slotLength <= diameter + 0.001
238
+ PcbScene3dBoardShapeFactory.#isCircularDrillSpec(
239
+ normalizedSpec
240
+ )
239
241
  const points = isCircular
240
242
  ? PcbScene3dBoardEdgeCutoutBuilder.buildCircularCutoutPoints(
241
243
  Number(normalizedSpec.x || 0),
@@ -338,7 +340,7 @@ export class PcbScene3dBoardShapeFactory {
338
340
  * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }[]}
339
341
  */
340
342
  static #resolvePlatedContours(THREE, detail, normalizeBoardPoint) {
341
- return PcbScene3dBoardShapeFactory.#resolvePlatedDrillSpecs(detail)
343
+ return PcbScene3dPlatedDrillSpecResolver.resolve(detail)
342
344
  .map((drillSpec) => {
343
345
  const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
344
346
  const normalizedSpec = {
@@ -373,14 +375,18 @@ export class PcbScene3dBoardShapeFactory {
373
375
 
374
376
  /**
375
377
  * Returns true when a drill aperture can be matched as a circle.
376
- * @param {{ diameter?: number, slotLength?: number | null }} drillSpec
378
+ * @param {{ diameter?: number, shape?: string, slotLength?: number | null }} drillSpec
377
379
  * @returns {boolean}
378
380
  */
379
381
  static #isCircularDrillSpec(drillSpec) {
380
382
  const diameter = Number(drillSpec?.diameter || 0)
381
383
  const slotLength = Number(drillSpec?.slotLength || 0)
382
384
 
383
- return diameter > 0 && slotLength <= diameter + 0.001
385
+ return (
386
+ diameter > 0 &&
387
+ drillSpec?.shape !== 'rect' &&
388
+ slotLength <= diameter + 0.001
389
+ )
384
390
  }
385
391
 
386
392
  /**
@@ -416,94 +422,6 @@ export class PcbScene3dBoardShapeFactory {
416
422
  }
417
423
  }
418
424
 
419
- /**
420
- * Resolves deduped drill specs that represent plated holes.
421
- * @param {{ pads?: any[], vias?: any[] }} detail
422
- * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
423
- */
424
- static #resolvePlatedDrillSpecs(detail) {
425
- const platedKeys = new Set()
426
-
427
- for (const via of detail?.vias || []) {
428
- const diameter = Number(via?.holeDiameter || 0)
429
- if (diameter <= 0) {
430
- continue
431
- }
432
-
433
- platedKeys.add(
434
- PcbScene3dBoardShapeFactory.#buildDrillSpecKey({
435
- x: Number(via?.x || 0),
436
- y: Number(via?.y || 0),
437
- diameter,
438
- slotLength: null,
439
- rotationDeg: 0
440
- })
441
- )
442
- }
443
-
444
- for (const pad of detail?.pads || []) {
445
- const diameter = Number(pad?.holeDiameter || 0)
446
- if (
447
- diameter <= 0 ||
448
- !PcbScene3dBoardShapeFactory.#hasPadCopperAnnulus(pad, diameter)
449
- ) {
450
- continue
451
- }
452
-
453
- const slotLength =
454
- Number(pad?.holeShape) ===
455
- PcbScene3dBoardShapeFactory.#PAD_HOLE_SHAPE_SLOT &&
456
- Number(pad?.holeSlotLength || 0) > diameter
457
- ? Number(pad?.holeSlotLength || 0)
458
- : null
459
- platedKeys.add(
460
- PcbScene3dBoardShapeFactory.#buildDrillSpecKey({
461
- x: Number(pad?.x || 0),
462
- y: Number(pad?.y || 0),
463
- diameter,
464
- slotLength,
465
- rotationDeg:
466
- slotLength === null
467
- ? 0
468
- : PcbScene3dBoardShapeFactory.#normalizeAngle(
469
- Number(pad?.rotation || 0) +
470
- Number(pad?.holeRotation || 0)
471
- )
472
- })
473
- )
474
- }
475
-
476
- return PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail).filter(
477
- (drillSpec) =>
478
- platedKeys.has(
479
- PcbScene3dBoardShapeFactory.#buildDrillSpecKey(drillSpec)
480
- )
481
- )
482
- }
483
-
484
- /**
485
- * Checks whether one pad has copper larger than its drill aperture.
486
- * @param {any} pad
487
- * @param {number} diameter
488
- * @returns {boolean}
489
- */
490
- static #hasPadCopperAnnulus(pad, diameter) {
491
- const drillSpan = Math.max(diameter, Number(pad?.holeSlotLength || 0))
492
-
493
- return [
494
- pad?.sizeTopX,
495
- pad?.sizeTopY,
496
- pad?.sizeMidX,
497
- pad?.sizeMidY,
498
- pad?.sizeBottomX,
499
- pad?.sizeBottomY
500
- ].some(
501
- (size) =>
502
- Number(size || 0) >
503
- drillSpan + PcbScene3dBoardShapeFactory.#GEOMETRY_EPSILON
504
- )
505
- }
506
-
507
425
  /**
508
426
  * Builds one closed contour and bounds from sampled path points.
509
427
  * @param {any[]} points
@@ -970,29 +888,4 @@ export class PcbScene3dBoardShapeFactory {
970
888
 
971
889
  return dx * dx + dy * dy
972
890
  }
973
-
974
- /**
975
- * Builds a stable drill spec key.
976
- * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
977
- * @returns {string}
978
- */
979
- static #buildDrillSpecKey(drillSpec) {
980
- return [
981
- Number(drillSpec.x || 0).toFixed(4),
982
- Number(drillSpec.y || 0).toFixed(4),
983
- Number(drillSpec.diameter || 0).toFixed(4),
984
- Number(drillSpec.slotLength || 0).toFixed(4),
985
- Number(drillSpec.rotationDeg || 0).toFixed(4)
986
- ].join(':')
987
- }
988
-
989
- /**
990
- * Normalizes one angle into the inclusive `[0, 360)` range.
991
- * @param {number} angleDeg
992
- * @returns {number}
993
- */
994
- static #normalizeAngle(angleDeg) {
995
- const normalized = Number(angleDeg || 0) % 360
996
- return normalized < 0 ? normalized + 360 : normalized
997
- }
998
891
  }
@@ -43,48 +43,71 @@ export class PcbScene3dBoardSolderMaskFactory {
43
43
  return group
44
44
  }
45
45
 
46
- const geometry = PcbScene3dBoardSolderMaskFactory.#buildMaskGeometry(
47
- THREE,
48
- board,
49
- sceneDescription?.detail || {},
50
- normalizeBoardPoint
51
- )
52
- const topMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
53
- THREE,
54
- board,
55
- THREE.FrontSide,
56
- sceneDescription?.sourceFormat
57
- )
58
- const bottomMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
59
- THREE,
60
- board,
61
- THREE.BackSide,
62
- sceneDescription?.sourceFormat
63
- )
64
- const z = Number(board?.thicknessMil || 0) / 2
65
-
66
- group.add(
67
- PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
46
+ const contours = PcbScene3dBoardSolderMaskFactory.#boardContours(board)
47
+ contours.forEach((contour, index) => {
48
+ const geometry =
49
+ PcbScene3dBoardSolderMaskFactory.#buildMaskGeometry(
50
+ THREE,
51
+ contour,
52
+ sceneDescription?.detail || {},
53
+ normalizeBoardPoint
54
+ )
55
+ const topMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
68
56
  THREE,
69
- geometry,
70
- topMaterial,
71
- z + PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
72
- 'board-solder-mask-top'
57
+ contour,
58
+ THREE.FrontSide,
59
+ sceneDescription?.sourceFormat
73
60
  )
74
- )
75
- group.add(
76
- PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
77
- THREE,
78
- geometry,
79
- bottomMaterial,
80
- -z - PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
81
- 'board-solder-mask-bottom'
61
+ const bottomMaterial =
62
+ PcbScene3dBoardSolderMaskFactory.#buildMaterial(
63
+ THREE,
64
+ contour,
65
+ THREE.BackSide,
66
+ sceneDescription?.sourceFormat
67
+ )
68
+ const z = Number(contour?.thicknessMil || 0) / 2
69
+ const suffix = contours.length === 1 ? '' : '-' + (index + 1)
70
+
71
+ group.add(
72
+ PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
73
+ THREE,
74
+ geometry,
75
+ topMaterial,
76
+ z + PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
77
+ 'board-solder-mask-top' + suffix
78
+ )
82
79
  )
83
- )
80
+ group.add(
81
+ PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
82
+ THREE,
83
+ geometry,
84
+ bottomMaterial,
85
+ -z - PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
86
+ 'board-solder-mask-bottom' + suffix
87
+ )
88
+ )
89
+ })
84
90
 
85
91
  return group
86
92
  }
87
93
 
94
+ /**
95
+ * Resolves independently renderable solder-mask contours.
96
+ * @param {object} board Aggregate board metadata.
97
+ * @returns {object[]}
98
+ */
99
+ static #boardContours(board) {
100
+ const contours = Array.isArray(board?.contours)
101
+ ? board.contours.filter(Boolean)
102
+ : []
103
+ if (!contours.length) return [board]
104
+ return contours.map((contour) => ({
105
+ ...board,
106
+ ...contour,
107
+ contours: []
108
+ }))
109
+ }
110
+
88
111
  /**
89
112
  * Builds one flat solder-mask face mesh.
90
113
  * @param {any} THREE
@@ -609,10 +632,13 @@ export class PcbScene3dBoardSolderMaskFactory {
609
632
  */
610
633
  static #buildMaterial(THREE, board, side, sourceFormat) {
611
634
  return new THREE.MeshStandardMaterial({
612
- color: PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
613
- hasBoardAssemblyModel: true,
614
- sourceFormat
615
- }),
635
+ color: PcbScene3dBoardMaterialPalette.resolveBoardSurfaceColor(
636
+ board,
637
+ {
638
+ hasBoardAssemblyModel: true,
639
+ sourceFormat
640
+ }
641
+ ),
616
642
  ...PcbScene3dMaterialFinish.semiMatteSolderMaskProperties(),
617
643
  polygonOffset: true,
618
644
  polygonOffsetFactor: -1,
@@ -1,18 +1,23 @@
1
1
  import {
2
- CircuitJsonDocument,
3
- CircuitJsonIndexer,
2
+ CircuitJsonDocumentContext,
4
3
  CircuitJsonUnits
5
4
  } from 'circuitjson-toolkit'
5
+ import { CircuitJsonPcbHolePrimitiveModel } from 'circuitjson-toolkit/extensions'
6
6
  import { PcbScene3dCircuitJsonGeometry } from './PcbScene3dCircuitJsonGeometry.mjs'
7
+ import { PcbScene3dCircuitJsonDrillDetail } from './PcbScene3dCircuitJsonDrillDetail.mjs'
7
8
  import { PcbScene3dCircuitJsonModelTransform } from './PcbScene3dCircuitJsonModelTransform.mjs'
8
9
  import { PcbScene3dCircuitJsonCopperPourBuilder } from './PcbScene3dCircuitJsonCopperPourBuilder.mjs'
9
10
  import { PcbScene3dCircuitJsonSilkscreenBuilder } from './PcbScene3dCircuitJsonSilkscreenBuilder.mjs'
10
11
  import { PcbScene3dFootprintBodyBuilder } from './PcbScene3dFootprintBodyBuilder.mjs'
11
12
  import { PcbScene3dCircuitJsonLayer } from './PcbScene3dCircuitJsonLayer.mjs'
13
+ import { PcbScene3dCircuitJsonInput } from './PcbScene3dCircuitJsonInput.mjs'
12
14
  import { PcbScene3dCircuitJsonTraceRouteBuilder } from './PcbScene3dCircuitJsonTraceRouteBuilder.mjs'
13
15
  import { PcbScene3dCircuitJsonThermalSpokeBuilder } from './PcbScene3dCircuitJsonThermalSpokeBuilder.mjs'
14
16
  import { PcbScene3dCircuitJsonModelUrlResolver } from './PcbScene3dCircuitJsonModelUrlResolver.mjs'
15
17
  import { PcbScene3dCircuitJsonSolderPasteBuilder } from './PcbScene3dCircuitJsonSolderPasteBuilder.mjs'
18
+ import { CircuitJsonCadModelAssetResolver } from './CircuitJsonCadModelAssetResolver.mjs'
19
+ import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
20
+ import { PcbScene3dCircuitJsonModelAsset } from './PcbScene3dCircuitJsonModelAsset.mjs'
16
21
 
17
22
  const DEFAULT_COMPONENT_HEIGHT_MIL = 60
18
23
  const RECTANGULAR_PAD_SHAPE = 2
@@ -39,7 +44,7 @@ export class PcbScene3dCircuitJsonAdapter {
39
44
  * @returns {boolean}
40
45
  */
41
46
  static isCircuitJsonModel(value) {
42
- return CircuitJsonDocument.isModel(value)
47
+ return PcbScene3dCircuitJsonInput.isModel(value)
43
48
  }
44
49
 
45
50
  /**
@@ -52,29 +57,61 @@ export class PcbScene3dCircuitJsonAdapter {
52
57
  return false
53
58
  }
54
59
 
55
- if (String(value?.sourceFormat || '') === 'circuitjson') {
60
+ if (!Array.isArray(value)) return true
61
+
62
+ if (
63
+ String(
64
+ PcbScene3dCircuitJsonAdapter.#ownData(value, 'sourceFormat') ||
65
+ ''
66
+ ) === 'circuitjson'
67
+ ) {
56
68
  return true
57
69
  }
58
70
 
59
71
  return !PcbScene3dCircuitJsonAdapter.#hasCompatibilityModel(value)
60
72
  }
61
73
 
74
+ /**
75
+ * Normalizes and validates one candidate through the shared CircuitJSON
76
+ * context while reusing an existing proof and element index.
77
+ * @param {unknown} circuitJson CircuitJSON model, document, or context.
78
+ * @returns {CircuitJsonDocumentContext} Prepared shared context.
79
+ */
80
+ static prepare(circuitJson) {
81
+ return CircuitJsonDocumentContext.prepare(circuitJson, {
82
+ indexes: ['elements']
83
+ })
84
+ }
85
+
62
86
  /**
63
87
  * Builds the internal render model used by the Three.js runtime.
64
- * @param {object[]} circuitJson Serialized CircuitJSON model.
88
+ * @param {unknown} circuitJson CircuitJSON model, document envelope, or prepared context.
65
89
  * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined, projectBaseUrl?: string, boardDrillQuality?: string, showPcbNotes?: boolean, showPcbPaste?: boolean }} [options] Adapter options.
66
90
  * @returns {object}
67
91
  */
68
92
  static build(circuitJson, options = {}) {
69
- CircuitJsonDocument.assertModel(circuitJson)
70
- const index = CircuitJsonIndexer.index(circuitJson)
71
- const board = PcbScene3dCircuitJsonGeometry.buildBoard(index, options)
72
- const detail = PcbScene3dCircuitJsonAdapter.#buildDetail(index, options)
93
+ const context = PcbScene3dCircuitJsonAdapter.prepare(circuitJson)
94
+ const index = context.getIndex('elements')
95
+ const resolvedOptions =
96
+ PcbScene3dCircuitJsonAdapter.#hasExternalModelReferences(index)
97
+ ? CircuitJsonCadModelAssetResolver.withContextAssetResolver(
98
+ options,
99
+ context
100
+ )
101
+ : options
102
+ const board = PcbScene3dCircuitJsonGeometry.buildBoard(
103
+ index,
104
+ resolvedOptions
105
+ )
106
+ const detail = PcbScene3dCircuitJsonAdapter.#buildDetail(
107
+ index,
108
+ resolvedOptions
109
+ )
73
110
  const externalPlacements =
74
111
  PcbScene3dCircuitJsonAdapter.#buildExternalPlacements(
75
112
  index,
76
113
  board,
77
- options
114
+ resolvedOptions
78
115
  )
79
116
  const externalPlacementByComponentId =
80
117
  PcbScene3dCircuitJsonAdapter.#externalPlacementByComponentId(
@@ -104,6 +141,33 @@ export class PcbScene3dCircuitJsonAdapter {
104
141
  }
105
142
  }
106
143
 
144
+ /**
145
+ * Returns whether CAD rows reference models that may need asset lookup.
146
+ * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
147
+ * @returns {boolean}
148
+ */
149
+ static #hasExternalModelReferences(index) {
150
+ return (index.elementsByType.get('cad_component') || []).some(
151
+ (component) =>
152
+ MODEL_URL_FIELDS.some(([field]) =>
153
+ String(
154
+ PcbScene3dCircuitJsonAdapter.#ownData(
155
+ component,
156
+ field
157
+ ) || ''
158
+ ).trim()
159
+ ) ||
160
+ Boolean(
161
+ PcbScene3dCircuitJsonModelAsset.reference(
162
+ PcbScene3dCircuitJsonAdapter.#ownData(
163
+ component,
164
+ 'model_asset'
165
+ )
166
+ )
167
+ )
168
+ )
169
+ }
170
+
107
171
  /**
108
172
  * Returns true for parser-produced arrays that already carry a legacy
109
173
  * renderer model and must keep using their format-specific scene builder.
@@ -111,7 +175,27 @@ export class PcbScene3dCircuitJsonAdapter {
111
175
  * @returns {boolean}
112
176
  */
113
177
  static #hasCompatibilityModel(value) {
114
- return Boolean(value?.pcb || value?.schematic || value?.bom)
178
+ return ['pcb', 'schematic', 'bom'].some((name) =>
179
+ Boolean(PcbScene3dCircuitJsonAdapter.#ownData(value, name))
180
+ )
181
+ }
182
+
183
+ /**
184
+ * Reads one own data property without invoking caller accessors.
185
+ * @param {unknown} value Record candidate.
186
+ * @param {string} name Property name.
187
+ * @returns {unknown} Own data value or undefined.
188
+ */
189
+ static #ownData(value, name) {
190
+ if (!value || typeof value !== 'object') return undefined
191
+ try {
192
+ const descriptor = Object.getOwnPropertyDescriptor(value, name)
193
+ return descriptor && Object.hasOwn(descriptor, 'value')
194
+ ? descriptor.value
195
+ : undefined
196
+ } catch {
197
+ return undefined
198
+ }
115
199
  }
116
200
 
117
201
  /**
@@ -405,12 +489,16 @@ export class PcbScene3dCircuitJsonAdapter {
405
489
  const match = MODEL_URL_FIELDS.find(([field]) =>
406
490
  String(cadComponent?.[field] || '').trim()
407
491
  )
408
- if (!match) {
409
- return null
410
- }
411
-
412
- const [field, format] = match
413
- const sourceUrl = String(cadComponent?.[field] || '').trim()
492
+ const reference = match
493
+ ? {
494
+ field: match[0],
495
+ format: match[1],
496
+ sourceUrl: String(cadComponent?.[match[0]] || '').trim()
497
+ }
498
+ : PcbScene3dCircuitJsonAdapter.#modelAssetReference(cadComponent)
499
+ if (!reference) return null
500
+
501
+ const { field, format, sourceUrl } = reference
414
502
  const context = {
415
503
  format,
416
504
  field,
@@ -432,6 +520,18 @@ export class PcbScene3dCircuitJsonAdapter {
432
520
  }
433
521
  }
434
522
 
523
+ /**
524
+ * Resolves a canonical `model_asset` fallback reference.
525
+ * @param {object} cadComponent CAD component element.
526
+ * @returns {{ field: string, format: string, sourceUrl: string } | null}
527
+ */
528
+ static #modelAssetReference(cadComponent) {
529
+ const reference = PcbScene3dCircuitJsonModelAsset.reference(
530
+ cadComponent?.model_asset
531
+ )
532
+ return reference ? { field: 'model_asset', ...reference } : null
533
+ }
534
+
435
535
  /**
436
536
  * Applies optional caller-owned model URL resolution.
437
537
  * @param {string} sourceUrl Source URL.
@@ -447,7 +547,7 @@ export class PcbScene3dCircuitJsonAdapter {
447
547
  return { resolvedUrl: resolved }
448
548
  }
449
549
  if (resolved && typeof resolved === 'object') {
450
- return { ...resolved }
550
+ return PcbScene3dDescriptorSafeRecord.copy(resolved)
451
551
  }
452
552
  }
453
553
 
@@ -663,13 +763,20 @@ export class PcbScene3dCircuitJsonAdapter {
663
763
  static #buildPlatedHoles(index) {
664
764
  return (index.elementsByType.get('pcb_plated_hole') || []).map(
665
765
  (hole) => {
766
+ const geometry = CircuitJsonPcbHolePrimitiveModel.build(hole, {
767
+ x: Number(hole?.x || 0),
768
+ y: Number(hole?.y || 0)
769
+ })
666
770
  const size =
667
- PcbScene3dCircuitJsonAdapter.#platedHoleOuterSize(hole)
771
+ PcbScene3dCircuitJsonAdapter.#platedHoleOuterSize(geometry)
668
772
  const center = CircuitJsonUnits.pointMmToMil({
669
773
  x: hole?.x,
670
774
  y: hole?.y
671
775
  })
672
- const drill = PcbScene3dCircuitJsonGeometry.holeDrillSpec(hole)
776
+ const drill = PcbScene3dCircuitJsonGeometry.holeDrillSpec(
777
+ hole,
778
+ geometry
779
+ )
673
780
  const exposesCopper =
674
781
  PcbScene3dCircuitJsonAdapter.#exposesCopperThroughMask(
675
782
  hole,
@@ -678,19 +785,18 @@ export class PcbScene3dCircuitJsonAdapter {
678
785
  return {
679
786
  x: center.x,
680
787
  y: center.y,
681
- rotation: Number(hole?.ccw_rotation || 0),
682
- shapeTop: PcbScene3dCircuitJsonAdapter.#padShape(hole),
683
- shapeMid: PcbScene3dCircuitJsonAdapter.#padShape(hole),
684
- shapeBottom: PcbScene3dCircuitJsonAdapter.#padShape(hole),
788
+ rotation: Number(geometry.rotation || 0),
789
+ shapeTop: PcbScene3dCircuitJsonAdapter.#padShape(geometry),
790
+ shapeMid: PcbScene3dCircuitJsonAdapter.#padShape(geometry),
791
+ shapeBottom:
792
+ PcbScene3dCircuitJsonAdapter.#padShape(geometry),
685
793
  sizeTopX: size.width,
686
794
  sizeTopY: size.height,
687
795
  sizeMidX: size.width,
688
796
  sizeMidY: size.height,
689
797
  sizeBottomX: size.width,
690
798
  sizeBottomY: size.height,
691
- holeDiameter: drill.diameter,
692
- holeSlotLength: drill.slotLength,
693
- holeRotation: drill.rotationDeg,
799
+ ...PcbScene3dCircuitJsonDrillDetail.fields(drill),
694
800
  holeOffsetX: drill.center.x - center.x,
695
801
  holeOffsetY: drill.center.y - center.y,
696
802
  hasTopSolderMaskOpening: exposesCopper,
@@ -721,9 +827,7 @@ export class PcbScene3dCircuitJsonAdapter {
721
827
  sizeMidY: 0,
722
828
  sizeBottomX: 0,
723
829
  sizeBottomY: 0,
724
- holeDiameter: drill.diameter,
725
- holeSlotLength: drill.slotLength,
726
- holeRotation: drill.rotationDeg,
830
+ ...PcbScene3dCircuitJsonDrillDetail.fields(drill),
727
831
  hasTopSolderMaskOpening: false,
728
832
  hasBottomSolderMaskOpening: false
729
833
  }
@@ -782,10 +886,17 @@ export class PcbScene3dCircuitJsonAdapter {
782
886
  * @returns {number}
783
887
  */
784
888
  static #roundedPadCornerRadiusPercent(pad, size) {
889
+ const width = CircuitJsonUnits.optionalLength(pad?.width)
890
+ const height = CircuitJsonUnits.optionalLength(pad?.height)
891
+ const radius = CircuitJsonUnits.optionalLength(pad?.radius)
892
+ if (width > 0 && height > 0 && radius > 0) {
893
+ return Math.min((radius / Math.min(width, height)) * 100, 50)
894
+ }
895
+
785
896
  const shortestSide = Math.min(Number(size.width), Number(size.height))
786
- const radius = CircuitJsonUnits.mmToMil(pad?.radius, 0)
787
- if (shortestSide > 0 && radius > 0) {
788
- return Math.min((radius / shortestSide) * 100, 50)
897
+ const radiusMil = CircuitJsonUnits.mmToMil(pad?.radius, 0)
898
+ if (shortestSide > 0 && radiusMil > 0) {
899
+ return Math.min((radiusMil / shortestSide) * 100, 50)
789
900
  }
790
901
 
791
902
  return 50
@@ -793,23 +904,13 @@ export class PcbScene3dCircuitJsonAdapter {
793
904
 
794
905
  /**
795
906
  * Resolves plated-hole copper size from CircuitJSON fields.
796
- * @param {object} hole Plated-hole element.
907
+ * @param {object} geometry Shared drilled-primitive geometry.
797
908
  * @returns {{ width: number, height: number }}
798
909
  */
799
- static #platedHoleOuterSize(hole) {
800
- if (Number(hole?.outer_diameter || 0) > 0) {
801
- const diameter = CircuitJsonUnits.mmToMil(hole.outer_diameter, 1)
802
- return { width: diameter, height: diameter }
803
- }
910
+ static #platedHoleOuterSize(geometry) {
804
911
  return {
805
- width: CircuitJsonUnits.mmToMil(
806
- hole?.outer_width || hole?.rect_pad_width,
807
- 1
808
- ),
809
- height: CircuitJsonUnits.mmToMil(
810
- hole?.outer_height || hole?.rect_pad_height,
811
- 1
812
- )
912
+ width: CircuitJsonUnits.mmToMil(geometry?.width, 1),
913
+ height: CircuitJsonUnits.mmToMil(geometry?.height, 1)
813
914
  }
814
915
  }
815
916
 
@@ -820,7 +921,9 @@ export class PcbScene3dCircuitJsonAdapter {
820
921
  */
821
922
  static #padShape(pad) {
822
923
  const shape = String(pad?.shape || '')
823
- return shape.includes('rect') || shape.endsWith('pill')
924
+ return shape.includes('rect') ||
925
+ shape.includes('polygon') ||
926
+ shape.endsWith('pill')
824
927
  ? RECTANGULAR_PAD_SHAPE
825
928
  : CIRCULAR_PAD_SHAPE
826
929
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Maps canonical drill descriptors onto the viewer's compact pad-detail shape.
3
+ */
4
+ export class PcbScene3dCircuitJsonDrillDetail {
5
+ static #RECTANGULAR_HOLE_SHAPE = 1
6
+ static #SLOTTED_HOLE_SHAPE = 2
7
+
8
+ /**
9
+ * Builds common viewer drill fields without losing canonical aperture shape.
10
+ * @param {{ diameter: number, width: number, height: number, shape: 'circle' | 'pill' | 'rect', slotLength: number, rotationDeg: number }} drill Canonical drill descriptor.
11
+ * @returns {{ holeDiameter: number, holeSlotLength: number, holeWidth: number, holeHeight: number, holeShape: number | null, holeRotation: number }} Viewer drill fields.
12
+ */
13
+ static fields(drill) {
14
+ return {
15
+ holeDiameter: drill.diameter,
16
+ holeSlotLength: drill.slotLength,
17
+ holeWidth: drill.width,
18
+ holeHeight: drill.height,
19
+ holeShape:
20
+ drill.shape === 'rect'
21
+ ? PcbScene3dCircuitJsonDrillDetail.#RECTANGULAR_HOLE_SHAPE
22
+ : drill.slotLength > drill.diameter
23
+ ? PcbScene3dCircuitJsonDrillDetail.#SLOTTED_HOLE_SHAPE
24
+ : null,
25
+ holeRotation: drill.rotationDeg
26
+ }
27
+ }
28
+ }
29
+
30
+ Object.freeze(PcbScene3dCircuitJsonDrillDetail.prototype)
31
+ Object.freeze(PcbScene3dCircuitJsonDrillDetail)