pcb-scene3d-viewer 1.1.0 → 1.1.2

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.
@@ -27,18 +27,28 @@ export class PcbScene3dPadFactory {
27
27
  const geometryCache = new Map()
28
28
  const side = PcbScene3dPadFactory.#normalizeSide(options?.side)
29
29
  const mirrorY = Boolean(options?.mirrorY)
30
+ const normalizedPads = pads || []
31
+ const boardDrills = PcbScene3dDrillPathFactory.resolveBoardDrillSpecs({
32
+ pads: normalizedPads
33
+ })
30
34
 
31
- ;(pads || []).forEach((pad) => {
35
+ ;(normalizedPads || []).forEach((pad) => {
32
36
  if (!PcbScene3dPadFactory.#hasVisibleSurface(pad, side)) {
33
37
  return
34
38
  }
35
39
 
36
40
  const spec = PcbScene3dPadFactory.resolvePadSurfaceSpec(pad, side)
41
+ const drillCutouts = PcbScene3dPadFactory.#resolvePadDrillCutouts(
42
+ pad,
43
+ spec,
44
+ boardDrills,
45
+ mirrorY
46
+ )
37
47
  const geometry = PcbScene3dPadFactory.#resolveGeometry(
38
48
  THREE,
39
49
  geometryCache,
40
50
  spec,
41
- pad
51
+ drillCutouts
42
52
  )
43
53
  const point = PcbScene3dPadFactory.#normalizePoint(
44
54
  normalizeBoardPoint,
@@ -391,27 +401,22 @@ export class PcbScene3dPadFactory {
391
401
  * Resolves or creates one reusable geometry for the pad spec.
392
402
  * @param {any} THREE
393
403
  * @param {Map<string, any>} geometryCache
394
- * @param {any} pad
395
404
  * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number, cornerRadius: number, hasHole: boolean, holeDiameter: number, holeSlotLength: number | null, holeRotation: number }} spec
405
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]} drillCutouts
396
406
  * @returns {any}
397
407
  */
398
- static #resolveGeometry(THREE, geometryCache, spec, pad) {
399
- const cacheKey = [
400
- spec.kind,
401
- spec.width.toFixed(4),
402
- spec.height.toFixed(4),
403
- spec.cornerRadius.toFixed(4),
404
- spec.holeDiameter.toFixed(4),
405
- Number(spec.holeSlotLength || 0).toFixed(4),
406
- spec.holeRotation.toFixed(4)
407
- ].join(':')
408
+ static #resolveGeometry(THREE, geometryCache, spec, drillCutouts) {
409
+ const cacheKey = PcbScene3dPadFactory.#buildGeometryCacheKey(
410
+ spec,
411
+ drillCutouts
412
+ )
408
413
  const cached = geometryCache.get(cacheKey)
409
414
  if (cached) {
410
415
  return cached
411
416
  }
412
417
 
413
418
  let geometry
414
- if (spec.kind === 'circle' && !spec.hasHole) {
419
+ if (spec.kind === 'circle' && !drillCutouts.length) {
415
420
  geometry = new THREE.CylinderGeometry(
416
421
  spec.radius,
417
422
  spec.radius,
@@ -420,12 +425,14 @@ export class PcbScene3dPadFactory {
420
425
  )
421
426
  } else {
422
427
  const shape = PcbScene3dPadFactory.#buildOuterShape(THREE, spec)
423
- const drillHole = PcbScene3dDrillPathFactory.buildPadHolePath(
424
- THREE,
425
- pad
426
- )
427
- if (drillHole) {
428
- shape.holes.push(drillHole)
428
+ for (const drillCutout of drillCutouts) {
429
+ const drillHole = PcbScene3dDrillPathFactory.buildDrillPath(
430
+ THREE,
431
+ drillCutout
432
+ )
433
+ if (drillHole) {
434
+ shape.holes.push(drillHole)
435
+ }
429
436
  }
430
437
  geometry = new THREE.ExtrudeGeometry(shape, {
431
438
  depth: PcbScene3dPadFactory.#PAD_THICKNESS_MIL,
@@ -444,6 +451,200 @@ export class PcbScene3dPadFactory {
444
451
  return geometry
445
452
  }
446
453
 
454
+ /**
455
+ * Resolves drill apertures that intersect one pad's local copper face.
456
+ * @param {{ x?: number, y?: number, rotation?: number | null }} pad
457
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number, offsetX: number, offsetY: number }} spec
458
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]} boardDrills
459
+ * @param {boolean} mirrorY
460
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
461
+ */
462
+ static #resolvePadDrillCutouts(pad, spec, boardDrills, mirrorY) {
463
+ return (boardDrills || [])
464
+ .map((drillSpec) =>
465
+ PcbScene3dPadFactory.#toPadLocalDrillCutout(
466
+ pad,
467
+ spec,
468
+ drillSpec,
469
+ mirrorY
470
+ )
471
+ )
472
+ .filter((drillSpec) =>
473
+ PcbScene3dPadFactory.#drillTouchesPadSurface(drillSpec, spec)
474
+ )
475
+ }
476
+
477
+ /**
478
+ * Converts one board-space drill aperture into pad-local shape space.
479
+ * @param {{ x?: number, y?: number, rotation?: number | null }} pad
480
+ * @param {{ offsetX: number, offsetY: number }} spec
481
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
482
+ * @param {boolean} mirrorY
483
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }}
484
+ */
485
+ static #toPadLocalDrillCutout(pad, spec, drillSpec, mirrorY) {
486
+ const padRotationDeg = Number(pad?.rotation || 0)
487
+ const relative = {
488
+ x: Number(drillSpec?.x || 0) - Number(pad?.x || 0),
489
+ y: Number(drillSpec?.y || 0) - Number(pad?.y || 0)
490
+ }
491
+ if (mirrorY) {
492
+ relative.y *= -1
493
+ }
494
+
495
+ const local = PcbScene3dPadFactory.#rotatePoint(
496
+ relative,
497
+ -padRotationDeg
498
+ )
499
+ const localOffsetY = mirrorY ? -spec.offsetY : spec.offsetY
500
+ const diameter = Number(drillSpec?.diameter || 0)
501
+ const slotLength =
502
+ Number(drillSpec?.slotLength || 0) > diameter
503
+ ? Number(drillSpec.slotLength || 0)
504
+ : null
505
+
506
+ return {
507
+ x: local.x - spec.offsetX,
508
+ y: local.y - localOffsetY,
509
+ diameter,
510
+ slotLength,
511
+ rotationDeg: PcbScene3dPadFactory.#normalizeAngle(
512
+ Number(drillSpec?.rotationDeg || 0) - padRotationDeg
513
+ )
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Checks whether a local drill aperture overlaps the pad's surface bounds.
519
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
520
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number }} spec
521
+ * @returns {boolean}
522
+ */
523
+ static #drillTouchesPadSurface(drillSpec, spec) {
524
+ if (Number(drillSpec?.diameter || 0) <= 0) {
525
+ return false
526
+ }
527
+
528
+ const bounds = PcbScene3dPadFactory.#resolveDrillBounds(drillSpec)
529
+ const halfWidth = Number(spec.width || 0) / 2
530
+ const halfHeight = Number(spec.height || 0) / 2
531
+ const boundsOverlap =
532
+ bounds.maxX >= -halfWidth &&
533
+ bounds.minX <= halfWidth &&
534
+ bounds.maxY >= -halfHeight &&
535
+ bounds.minY <= halfHeight
536
+ if (!boundsOverlap) {
537
+ return false
538
+ }
539
+
540
+ if (spec.kind !== 'circle') {
541
+ return true
542
+ }
543
+
544
+ return (
545
+ Math.hypot(Number(drillSpec.x || 0), Number(drillSpec.y || 0)) <=
546
+ Number(spec.radius || 0) +
547
+ PcbScene3dPadFactory.#resolveDrillReach(drillSpec)
548
+ )
549
+ }
550
+
551
+ /**
552
+ * Resolves the axis-aligned local bounds of one drill aperture.
553
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
554
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
555
+ */
556
+ static #resolveDrillBounds(drillSpec) {
557
+ const radius = Math.max(Number(drillSpec.diameter || 0) / 2, 0)
558
+ const halfTrack = Math.max(
559
+ (Number(drillSpec.slotLength || 0) -
560
+ Number(drillSpec.diameter || 0)) /
561
+ 2,
562
+ 0
563
+ )
564
+ const rotationRad = (Number(drillSpec.rotationDeg || 0) * Math.PI) / 180
565
+ const extentX = Math.abs(Math.cos(rotationRad)) * halfTrack + radius
566
+ const extentY = Math.abs(Math.sin(rotationRad)) * halfTrack + radius
567
+
568
+ return {
569
+ minX: Number(drillSpec.x || 0) - extentX,
570
+ maxX: Number(drillSpec.x || 0) + extentX,
571
+ minY: Number(drillSpec.y || 0) - extentY,
572
+ maxY: Number(drillSpec.y || 0) + extentY
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Resolves a conservative radial reach for one drill aperture.
578
+ * @param {{ diameter: number, slotLength?: number | null }} drillSpec
579
+ * @returns {number}
580
+ */
581
+ static #resolveDrillReach(drillSpec) {
582
+ return (
583
+ Math.max(
584
+ Number(drillSpec.diameter || 0),
585
+ Number(drillSpec.slotLength || 0)
586
+ ) / 2
587
+ )
588
+ }
589
+
590
+ /**
591
+ * Rotates one 2D point around the origin.
592
+ * @param {{ x: number, y: number }} point
593
+ * @param {number} angleDeg
594
+ * @returns {{ x: number, y: number }}
595
+ */
596
+ static #rotatePoint(point, angleDeg) {
597
+ const angleRad = (Number(angleDeg || 0) * Math.PI) / 180
598
+ const cos = Math.cos(angleRad)
599
+ const sin = Math.sin(angleRad)
600
+
601
+ return {
602
+ x: point.x * cos - point.y * sin,
603
+ y: point.x * sin + point.y * cos
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Builds a cache key that includes pad geometry and local drill cutouts.
609
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', cornerRadius: number, holeDiameter: number, holeSlotLength: number | null, holeRotation: number }} spec
610
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]} drillCutouts
611
+ * @returns {string}
612
+ */
613
+ static #buildGeometryCacheKey(spec, drillCutouts) {
614
+ const drillKey = (drillCutouts || [])
615
+ .map((drillSpec) =>
616
+ [
617
+ Number(drillSpec.x || 0).toFixed(4),
618
+ Number(drillSpec.y || 0).toFixed(4),
619
+ Number(drillSpec.diameter || 0).toFixed(4),
620
+ Number(drillSpec.slotLength || 0).toFixed(4),
621
+ Number(drillSpec.rotationDeg || 0).toFixed(4)
622
+ ].join(',')
623
+ )
624
+ .join('|')
625
+
626
+ return [
627
+ spec.kind,
628
+ spec.width.toFixed(4),
629
+ spec.height.toFixed(4),
630
+ spec.cornerRadius.toFixed(4),
631
+ spec.holeDiameter.toFixed(4),
632
+ Number(spec.holeSlotLength || 0).toFixed(4),
633
+ spec.holeRotation.toFixed(4),
634
+ drillKey
635
+ ].join(':')
636
+ }
637
+
638
+ /**
639
+ * Normalizes one angle into the inclusive `[0, 360)` range.
640
+ * @param {number} angleDeg
641
+ * @returns {number}
642
+ */
643
+ static #normalizeAngle(angleDeg) {
644
+ const normalized = Number(angleDeg || 0) % 360
645
+ return normalized < 0 ? normalized + 360 : normalized
646
+ }
647
+
447
648
  /**
448
649
  * Builds one pad outline shape for extrusion.
449
650
  * @param {any} THREE
@@ -9,7 +9,7 @@ export class PcbScene3dRenderGroupVisibility {
9
9
 
10
10
  /**
11
11
  * Applies the active 3D detail visibility state.
12
- * @param {{ groups: Map<string, any>, toggles: { 'external-models': boolean, 'fallback-bodies': boolean, copper: boolean }, fallbackBodyRoots: Map<string, Set<any>>, loadedExternalModelDesignators: Set<string>, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
12
+ * @param {{ groups: Map<string, any>, toggles: { 'external-models': boolean, 'fallback-bodies': boolean, 'model-search-models'?: boolean, copper: boolean }, fallbackBodyRoots: Map<string, Set<any>>, loadedExternalModelDesignators: Set<string>, modelSearchExternalModelRoots?: Set<any>, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
13
13
  * @returns {void}
14
14
  */
15
15
  static apply(state) {
@@ -51,6 +51,28 @@ export class PcbScene3dRenderGroupVisibility {
51
51
  state?.groups?.get('external-models'),
52
52
  Boolean(state?.toggles?.['external-models'])
53
53
  )
54
+ PcbScene3dRenderGroupVisibility.#applyModelSearchExternalVisibility(
55
+ state
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Applies the app-discovered model toggle to individual external roots.
61
+ * @param {{ toggles?: { 'external-models'?: boolean, 'model-search-models'?: boolean }, modelSearchExternalModelRoots?: Set<any> }} state Visibility state.
62
+ * @returns {void}
63
+ */
64
+ static #applyModelSearchExternalVisibility(state) {
65
+ const roots = state?.modelSearchExternalModelRoots
66
+ if (!roots || typeof roots.forEach !== 'function') {
67
+ return
68
+ }
69
+
70
+ const visible =
71
+ Boolean(state?.toggles?.['external-models']) &&
72
+ state?.toggles?.['model-search-models'] !== false
73
+ roots.forEach((root) => {
74
+ PcbScene3dRenderGroupVisibility.#setVisible(root, visible)
75
+ })
54
76
  }
55
77
 
56
78
  /**
@@ -2,6 +2,8 @@ import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFac
2
2
  import { PcbScene3dBodyColor } from './PcbScene3dBodyColor.mjs'
3
3
  import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
4
4
  import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
5
+ import { PcbScene3dComponentAdjustment } from './PcbScene3dComponentAdjustment.mjs'
6
+ import { PcbScene3dComponentAdjustmentRegistry } from './PcbScene3dComponentAdjustmentRegistry.mjs'
5
7
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
6
8
  import { PcbScene3dCopperDetailFilter } from './PcbScene3dCopperDetailFilter.mjs'
7
9
  import { PcbScene3dDetailCoordinateNormalizer } from './PcbScene3dDetailCoordinateNormalizer.mjs'
@@ -10,15 +12,18 @@ import { PcbScene3dExternalModels } from './PcbScene3dExternalModels.mjs'
10
12
  import { PcbScene3dFallbackVisibility } from './PcbScene3dFallbackVisibility.mjs'
11
13
  import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
12
14
  import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
15
+ import { PcbScene3dModelSearchPlacement } from './PcbScene3dModelSearchPlacement.mjs'
13
16
  import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
14
17
  import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
15
18
  import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
16
19
  import { PcbScene3dSilkscreenChunkedFactory } from './PcbScene3dSilkscreenChunkedFactory.mjs'
17
20
  import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
21
+ import { PcbScene3dSelectionResolver } from './PcbScene3dSelectionResolver.mjs'
18
22
  import { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
19
23
  import { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
20
24
  import { PcbScene3dViewportResize } from './PcbScene3dViewportResize.mjs'
21
25
  import { PcbScene3dViewScale } from './PcbScene3dViewScale.mjs'
26
+ const Z_MIL = { cu: 0.05, silk: 0.06 }
22
27
  /**
23
28
  * Browser-side Three.js runtime for the interactive PCB 3D viewport.
24
29
  */
@@ -29,7 +34,7 @@ export class PcbScene3dRuntime {
29
34
  #sceneDescription
30
35
  /** @type {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null }} */
31
36
  #hooks
32
- /** @type {{ 'external-models': boolean, 'fallback-bodies': boolean, copper: boolean }} */
37
+ /** @type {{ 'external-models': boolean, 'fallback-bodies': boolean, 'model-search-models': boolean, copper: boolean }} */
33
38
  #toggles
34
39
  /** @type {Map<string, any>} */
35
40
  #groups
@@ -61,10 +66,14 @@ export class PcbScene3dRuntime {
61
66
  #pointerDownPosition
62
67
  /** @type {Map<string, Set<any>>} */
63
68
  #selectionRoots
69
+ /** @type {PcbScene3dComponentAdjustmentRegistry} */
70
+ #componentAdjustmentRegistry
64
71
  /** @type {Map<string, Set<any>>} */
65
72
  #fallbackBodyRoots
66
73
  /** @type {Set<string>} */
67
74
  #loadedExternalModelDesignators
75
+ /** @type {Set<any>} */
76
+ #modelSearchExternalModelRoots
68
77
  /** @type {boolean} */
69
78
  #hasLoadedBoardAssemblyModel
70
79
  /** @type {string} */
@@ -81,7 +90,6 @@ export class PcbScene3dRuntime {
81
90
  #resolveReadyPromise
82
91
  /** @type {boolean} */
83
92
  #hasSettledReady
84
-
85
93
  /**
86
94
  * @param {HTMLElement} viewportNode
87
95
  * @param {any} sceneDescription Scene description or CircuitJSON model.
@@ -96,6 +104,7 @@ export class PcbScene3dRuntime {
96
104
  this.#toggles = {
97
105
  'external-models': true,
98
106
  'fallback-bodies': false,
107
+ 'model-search-models': true,
99
108
  copper: true
100
109
  }
101
110
  this.#groups = new Map()
@@ -113,8 +122,11 @@ export class PcbScene3dRuntime {
113
122
  this.#pointer = null
114
123
  this.#pointerDownPosition = null
115
124
  this.#selectionRoots = new Map()
125
+ this.#componentAdjustmentRegistry =
126
+ new PcbScene3dComponentAdjustmentRegistry(() => this.#three)
116
127
  this.#fallbackBodyRoots = new Map()
117
128
  this.#loadedExternalModelDesignators = new Set()
129
+ this.#modelSearchExternalModelRoots = new Set()
118
130
  this.#hasLoadedBoardAssemblyModel = false
119
131
  this.#selectedDesignator = ''
120
132
  this.#initialRadius =
@@ -134,7 +146,6 @@ export class PcbScene3dRuntime {
134
146
  ])
135
147
  this.#initialize()
136
148
  }
137
-
138
149
  /** @param {string} preset */
139
150
  setPreset(preset) {
140
151
  const normalizedPreset = this.#presetState.set(preset)
@@ -151,7 +162,6 @@ export class PcbScene3dRuntime {
151
162
  )
152
163
  this.#render()
153
164
  }
154
-
155
165
  /** @param {string} toggleName @param {boolean} enabled */
156
166
  setToggle(toggleName, enabled) {
157
167
  if (!(toggleName in this.#toggles)) {
@@ -168,6 +178,18 @@ export class PcbScene3dRuntime {
168
178
  this.#setSelectedDesignator(designator)
169
179
  }
170
180
 
181
+ /**
182
+ * Applies a live model-local adjustment to one component.
183
+ * @param {string} designator Component designator.
184
+ * @param {{ scale?: { x?: number, y?: number, z?: number }, rotationDeg?: { x?: number, y?: number, z?: number }, offsetMil?: { x?: number, y?: number, z?: number } }} adjustment Transform adjustment.
185
+ * @returns {void}
186
+ */
187
+ setComponentAdjustment(designator, adjustment) {
188
+ if (this.#componentAdjustmentRegistry.set(designator, adjustment)) {
189
+ this.#render()
190
+ }
191
+ }
192
+
171
193
  /**
172
194
  * Resolves when the runtime has completed its initial async
173
195
  * initialization and deferred scene settlement.
@@ -204,8 +226,10 @@ export class PcbScene3dRuntime {
204
226
  this.#pointer = null
205
227
  this.#pointerDownPosition = null
206
228
  this.#selectionRoots.clear()
229
+ this.#componentAdjustmentRegistry.clear()
207
230
  this.#fallbackBodyRoots.clear()
208
231
  this.#loadedExternalModelDesignators.clear()
232
+ this.#modelSearchExternalModelRoots.clear()
209
233
  this.#hasLoadedBoardAssemblyModel = false
210
234
  this.#selectedDesignator = ''
211
235
  this.#groups.clear()
@@ -464,18 +488,18 @@ export class PcbScene3dRuntime {
464
488
  await PcbScene3dTrueTypeTextFactory.prepareEmbeddedFonts(
465
489
  this.#sceneDescription.detail.embeddedFonts || []
466
490
  )
491
+ const topZ = this.#sceneDescription.board.thicknessMil / 2 + Z_MIL.silk
467
492
  const detailGroup = await PcbScene3dSilkscreenChunkedFactory.buildGroup(
468
493
  this.#three,
469
494
  this.#sceneDescription.detail.silkscreen || {},
470
- this.#sceneDescription.board.thicknessMil / 2 + 1.18,
471
- -(this.#sceneDescription.board.thicknessMil / 2 + 1.18),
495
+ topZ,
496
+ -topZ,
472
497
  (x, y) => this.#normalizeDetailPoint(x, y),
473
498
  {
474
499
  shouldContinue: () => !this.#isDisposed,
475
500
  yieldToMain: () => PcbScene3dRuntime.#yieldToNextFrame()
476
501
  }
477
502
  )
478
-
479
503
  if (this.#isDisposed) return
480
504
  if (detailGroup.children.length) {
481
505
  silkscreenGroup.add(detailGroup)
@@ -492,15 +516,15 @@ export class PcbScene3dRuntime {
492
516
  if (!copperGroup || copperGroup.children.length) {
493
517
  return
494
518
  }
495
-
496
519
  const copperDetail = PcbScene3dCopperDetailFilter.resolve(
497
520
  this.#sceneDescription
498
521
  )
522
+ const topZ = this.#sceneDescription.board.thicknessMil / 2 + Z_MIL.cu
499
523
  const detailGroup = PcbScene3dCopperFactory.buildGroup(
500
524
  this.#three,
501
525
  copperDetail,
502
- this.#sceneDescription.board.thicknessMil / 2 + 0.9,
503
- -(this.#sceneDescription.board.thicknessMil / 2 + 0.9),
526
+ topZ,
527
+ -topZ,
504
528
  (x, y) => this.#normalizeDetailPoint(x, y),
505
529
  { coordinateSystem: this.#sceneDescription.coordinateSystem }
506
530
  )
@@ -564,14 +588,25 @@ export class PcbScene3dRuntime {
564
588
 
565
589
  const mountRig = PcbScene3dMountRig.create(THREE, component)
566
590
  const rootGroup = mountRig.rootGroup
591
+ const adjustmentGroup = new THREE.Group()
567
592
 
568
593
  rootGroup.userData.scene3dSelection = {
569
594
  designator: String(component?.designator || 'component'),
570
595
  sourceType: 'component'
571
596
  }
597
+ adjustmentGroup.userData.scene3dAdjustmentTarget = true
598
+ adjustmentGroup.userData.scene3dAdjustmentDesignator = String(
599
+ component?.designator || 'component'
600
+ )
601
+ adjustmentGroup.userData.scene3dAdjustmentBaseline =
602
+ PcbScene3dComponentAdjustment.neutral()
572
603
  this.#registerSelectionRoot(component?.designator, rootGroup)
573
- mountRig.faceGroup.add(mesh)
574
-
604
+ this.#componentAdjustmentRegistry.register(
605
+ component?.designator,
606
+ adjustmentGroup
607
+ )
608
+ adjustmentGroup.add(mesh)
609
+ mountRig.faceGroup.add(adjustmentGroup)
575
610
  return rootGroup
576
611
  }
577
612
 
@@ -599,6 +634,19 @@ export class PcbScene3dRuntime {
599
634
  placement?.designator,
600
635
  placementGroup
601
636
  )
637
+ PcbScene3dComponentAdjustment.findTargets(
638
+ placementGroup
639
+ ).forEach((target) => {
640
+ this.#componentAdjustmentRegistry.register(
641
+ placement?.designator,
642
+ target
643
+ )
644
+ })
645
+ PcbScene3dModelSearchPlacement.registerRoot(
646
+ placement,
647
+ placementGroup,
648
+ this.#modelSearchExternalModelRoots
649
+ )
602
650
  if (
603
651
  String(placement?.sourceType || '').toLowerCase() ===
604
652
  'board-assembly'
@@ -728,6 +776,7 @@ export class PcbScene3dRuntime {
728
776
  fallbackBodyRoots: this.#fallbackBodyRoots,
729
777
  loadedExternalModelDesignators:
730
778
  this.#loadedExternalModelDesignators,
779
+ modelSearchExternalModelRoots: this.#modelSearchExternalModelRoots,
731
780
  hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
732
781
  })
733
782
  }
@@ -850,7 +899,7 @@ export class PcbScene3dRuntime {
850
899
  true
851
900
  )
852
901
  const selection =
853
- PcbScene3dRuntime.#resolveSelectionFromIntersections(intersections)
902
+ PcbScene3dSelectionResolver.fromIntersections(intersections)
854
903
  this.#setSelectedDesignator(selection?.designator)
855
904
  this.#hooks.setSelection?.(selection)
856
905
  }
@@ -930,44 +979,6 @@ export class PcbScene3dRuntime {
930
979
  return PcbScene3dViewScale.resolve(preset, sceneDescription)
931
980
  }
932
981
 
933
- /**
934
- * Resolves the picked selection record from raycast intersections.
935
- * @param {{ object?: any }[]} intersections
936
- * @returns {{ designator?: string, sourceType?: string } | null}
937
- */
938
- static #resolveSelectionFromIntersections(intersections) {
939
- for (const intersection of intersections) {
940
- const selection = PcbScene3dRuntime.#resolveSelectionFromObject(
941
- intersection?.object
942
- )
943
- if (selection) {
944
- return selection
945
- }
946
- }
947
-
948
- return null
949
- }
950
-
951
- /**
952
- * Resolves one selection payload by walking up a picked object tree.
953
- * @param {any} object
954
- * @returns {{ designator?: string, sourceType?: string } | null}
955
- */
956
- static #resolveSelectionFromObject(object) {
957
- let current = object
958
-
959
- while (current) {
960
- const selection = current?.userData?.scene3dSelection
961
- if (selection) {
962
- return selection
963
- }
964
-
965
- current = current.parent || null
966
- }
967
-
968
- return null
969
- }
970
-
971
982
  /** @returns {number} */
972
983
  static #resolveSelectionHighlightColor() {
973
984
  return 0x14c5e6