pcb-scene3d-viewer 1.1.47 → 1.1.49

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.
@@ -0,0 +1,235 @@
1
+ import polygonClipping from 'polygon-clipping'
2
+
3
+ /**
4
+ * Normalizes and subtracts copper fill loop sets with polygon booleans.
5
+ */
6
+ export class PcbScene3dCopperFillPolygonBoolean {
7
+ static #AREA_EPSILON = 0.001
8
+
9
+ /**
10
+ * Resolves the remaining loop sets after subtracting already emitted
11
+ * copper areas.
12
+ * @param {{ outer: number[][], holes: number[][][], bounds: object }} loopSet Candidate loop set.
13
+ * @param {number[][][][]} emittedPolygons Already emitted polygon-clipping polygons.
14
+ * @returns {{ outer: number[][], holes: number[][][], bounds: object }[] | null}
15
+ */
16
+ static resolveRemainingLoopSets(loopSet, emittedPolygons) {
17
+ const subjectPolygons =
18
+ PcbScene3dCopperFillPolygonBoolean.resolveNormalizedPolygons(
19
+ loopSet
20
+ )
21
+ if (!subjectPolygons.length) {
22
+ return []
23
+ }
24
+
25
+ if (!emittedPolygons.length) {
26
+ return PcbScene3dCopperFillPolygonBoolean.#multiPolygonToLoopSets(
27
+ subjectPolygons
28
+ )
29
+ }
30
+
31
+ try {
32
+ return PcbScene3dCopperFillPolygonBoolean.#multiPolygonToLoopSets(
33
+ polygonClipping.difference(subjectPolygons, ...emittedPolygons)
34
+ )
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Normalizes one loop set with polygon boolean semantics.
42
+ * @param {{ outer: number[][], holes: number[][][] }} loopSet Normalized loop set.
43
+ * @returns {number[][][][]}
44
+ */
45
+ static resolveNormalizedPolygons(loopSet) {
46
+ const subject =
47
+ PcbScene3dCopperFillPolygonBoolean.#loopSetToPolygon(loopSet)
48
+ if (!subject) {
49
+ return []
50
+ }
51
+
52
+ try {
53
+ return polygonClipping.union(subject)
54
+ } catch {
55
+ return [subject]
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Converts one normalized loop set to a polygon-clipping polygon.
61
+ * @param {{ outer: number[][], holes: number[][][] }} loopSet Normalized loop set.
62
+ * @returns {number[][][] | null}
63
+ */
64
+ static #loopSetToPolygon(loopSet) {
65
+ const outer = PcbScene3dCopperFillPolygonBoolean.#closedRing(
66
+ loopSet?.outer
67
+ )
68
+ if (!outer.length) {
69
+ return null
70
+ }
71
+
72
+ return [
73
+ outer,
74
+ ...(loopSet?.holes || [])
75
+ .map((hole) =>
76
+ PcbScene3dCopperFillPolygonBoolean.#closedRing(hole)
77
+ )
78
+ .filter((hole) => hole.length)
79
+ ]
80
+ }
81
+
82
+ /**
83
+ * Converts polygon-clipping output into normalized loop sets.
84
+ * @param {number[][][][]} multiPolygon Clipped multipolygon.
85
+ * @returns {{ outer: number[][], holes: number[][][], bounds: object }[]}
86
+ */
87
+ static #multiPolygonToLoopSets(multiPolygon) {
88
+ return (multiPolygon || [])
89
+ .map((polygon) => {
90
+ const outer = PcbScene3dCopperFillPolygonBoolean.#cleanLoop(
91
+ polygon?.[0] || []
92
+ )
93
+ if (!PcbScene3dCopperFillPolygonBoolean.#isValidLoop(outer)) {
94
+ return null
95
+ }
96
+
97
+ const holes = (polygon || [])
98
+ .slice(1)
99
+ .map((ring) =>
100
+ PcbScene3dCopperFillPolygonBoolean.#cleanLoop(ring)
101
+ )
102
+ .filter((ring) =>
103
+ PcbScene3dCopperFillPolygonBoolean.#isValidLoop(ring)
104
+ )
105
+
106
+ return {
107
+ outer,
108
+ holes,
109
+ bounds: PcbScene3dCopperFillPolygonBoolean.#loopBounds(
110
+ outer
111
+ )
112
+ }
113
+ })
114
+ .filter(Boolean)
115
+ }
116
+
117
+ /**
118
+ * Closes one loop for polygon boolean operations.
119
+ * @param {number[][]} loop Candidate loop.
120
+ * @returns {number[][]}
121
+ */
122
+ static #closedRing(loop) {
123
+ const cleanLoop = PcbScene3dCopperFillPolygonBoolean.#cleanLoop(loop)
124
+ if (!PcbScene3dCopperFillPolygonBoolean.#isValidLoop(cleanLoop)) {
125
+ return []
126
+ }
127
+
128
+ const first = cleanLoop[0]
129
+ const last = cleanLoop[cleanLoop.length - 1]
130
+ const closed = cleanLoop.map((point) => [point[0], point[1]])
131
+ if (
132
+ Math.abs(first[0] - last[0]) >=
133
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON ||
134
+ Math.abs(first[1] - last[1]) >=
135
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON
136
+ ) {
137
+ closed.push([first[0], first[1]])
138
+ }
139
+ return closed
140
+ }
141
+
142
+ /**
143
+ * Removes invalid and duplicate loop points.
144
+ * @param {number[][]} points Candidate points.
145
+ * @returns {number[][]}
146
+ */
147
+ static #cleanLoop(points) {
148
+ const loop = []
149
+ for (const point of points || []) {
150
+ const x = Number(point?.[0])
151
+ const y = Number(point?.[1])
152
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
153
+ continue
154
+ }
155
+
156
+ const previous = loop[loop.length - 1]
157
+ if (
158
+ previous &&
159
+ Math.abs(previous[0] - x) <
160
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON &&
161
+ Math.abs(previous[1] - y) <
162
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON
163
+ ) {
164
+ continue
165
+ }
166
+ loop.push([x, y])
167
+ }
168
+
169
+ const first = loop[0]
170
+ const last = loop[loop.length - 1]
171
+ if (
172
+ first &&
173
+ last &&
174
+ Math.abs(first[0] - last[0]) <
175
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON &&
176
+ Math.abs(first[1] - last[1]) <
177
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON
178
+ ) {
179
+ loop.pop()
180
+ }
181
+
182
+ return loop
183
+ }
184
+
185
+ /**
186
+ * Checks whether one loop has enough non-collinear area.
187
+ * @param {number[][]} loop Candidate loop.
188
+ * @returns {boolean}
189
+ */
190
+ static #isValidLoop(loop) {
191
+ return (
192
+ Array.isArray(loop) &&
193
+ loop.length >= 3 &&
194
+ Math.abs(PcbScene3dCopperFillPolygonBoolean.#signedArea(loop)) >
195
+ PcbScene3dCopperFillPolygonBoolean.#AREA_EPSILON
196
+ )
197
+ }
198
+
199
+ /**
200
+ * Computes axis-aligned bounds for one loop.
201
+ * @param {number[][]} loop Candidate loop.
202
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
203
+ */
204
+ static #loopBounds(loop) {
205
+ return (loop || []).reduce(
206
+ (bounds, point) => ({
207
+ minX: Math.min(bounds.minX, Number(point?.[0])),
208
+ minY: Math.min(bounds.minY, Number(point?.[1])),
209
+ maxX: Math.max(bounds.maxX, Number(point?.[0])),
210
+ maxY: Math.max(bounds.maxY, Number(point?.[1]))
211
+ }),
212
+ {
213
+ minX: Infinity,
214
+ minY: Infinity,
215
+ maxX: -Infinity,
216
+ maxY: -Infinity
217
+ }
218
+ )
219
+ }
220
+
221
+ /**
222
+ * Computes signed loop area.
223
+ * @param {number[][]} loop Candidate loop.
224
+ * @returns {number}
225
+ */
226
+ static #signedArea(loop) {
227
+ let area = 0
228
+ for (let index = 0; index < loop.length; index += 1) {
229
+ const current = loop[index]
230
+ const next = loop[(index + 1) % loop.length]
231
+ area += current[0] * next[1] - next[0] * current[1]
232
+ }
233
+ return area / 2
234
+ }
235
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Appends shallow copper-prism triangles to packed position buffers.
3
+ */
4
+ export class PcbScene3dCopperPrismBuilder {
5
+ static #COPPER_THICKNESS_MIL = 2.2
6
+
7
+ /**
8
+ * Returns the visual copper extrusion thickness.
9
+ * @returns {number}
10
+ */
11
+ static thicknessMil() {
12
+ return PcbScene3dCopperPrismBuilder.#COPPER_THICKNESS_MIL
13
+ }
14
+
15
+ /**
16
+ * Returns half the visual copper extrusion thickness.
17
+ * @returns {number}
18
+ */
19
+ static halfThicknessMil() {
20
+ return PcbScene3dCopperPrismBuilder.#COPPER_THICKNESS_MIL / 2
21
+ }
22
+
23
+ /**
24
+ * Appends one shallow triangular prism into the position buffer.
25
+ * @param {number[]} positions Position buffer.
26
+ * @param {{ x: number, y: number }} a First point.
27
+ * @param {{ x: number, y: number }} b Second point.
28
+ * @param {{ x: number, y: number }} c Third point.
29
+ * @param {number} z Center Z position.
30
+ * @returns {void}
31
+ */
32
+ static appendTriangle(positions, a, b, c, z) {
33
+ const halfThickness = PcbScene3dCopperPrismBuilder.halfThicknessMil()
34
+ const topZ = z + halfThickness
35
+ const bottomZ = z - halfThickness
36
+ positions.push(
37
+ a.x,
38
+ a.y,
39
+ topZ,
40
+ b.x,
41
+ b.y,
42
+ topZ,
43
+ c.x,
44
+ c.y,
45
+ topZ,
46
+ c.x,
47
+ c.y,
48
+ bottomZ,
49
+ b.x,
50
+ b.y,
51
+ bottomZ,
52
+ a.x,
53
+ a.y,
54
+ bottomZ
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Appends a side wall for one actual copper boundary edge.
60
+ * @param {number[]} positions Position buffer.
61
+ * @param {{ x: number, y: number }} start Wall start point.
62
+ * @param {{ x: number, y: number }} end Wall end point.
63
+ * @param {number} z Center Z position.
64
+ * @returns {void}
65
+ */
66
+ static appendBoundarySideTriangles(positions, start, end, z) {
67
+ const halfThickness = PcbScene3dCopperPrismBuilder.halfThicknessMil()
68
+ const topZ = z + halfThickness
69
+ const bottomZ = z - halfThickness
70
+
71
+ positions.push(
72
+ start.x,
73
+ start.y,
74
+ topZ,
75
+ end.x,
76
+ end.y,
77
+ topZ,
78
+ end.x,
79
+ end.y,
80
+ bottomZ,
81
+ start.x,
82
+ start.y,
83
+ topZ,
84
+ end.x,
85
+ end.y,
86
+ bottomZ,
87
+ start.x,
88
+ start.y,
89
+ bottomZ
90
+ )
91
+ }
92
+ }
@@ -33,15 +33,13 @@ export class PcbScene3dDrillVoidFactory {
33
33
  return group
34
34
  }
35
35
 
36
- const material = PcbScene3dDrillVoidFactory.#buildMaterial(
36
+ const material = PcbScene3dDrillVoidFactory.#buildInteriorMaterial(
37
37
  THREE,
38
38
  options
39
39
  )
40
40
  const geometryCache = new Map()
41
41
  const depth = Math.max(Math.abs(Number(topZ) - Number(bottomZ)), 1)
42
42
  const centerZ = (Number(topZ || 0) + Number(bottomZ || 0)) / 2
43
- const platedDrillKeys =
44
- PcbScene3dDrillVoidFactory.#buildPlatedDrillKeySet(detail)
45
43
  const edgeDrillKeys = PcbScene3dDrillVoidFactory.#buildEdgeDrillKeySet(
46
44
  THREE,
47
45
  detail,
@@ -52,9 +50,7 @@ export class PcbScene3dDrillVoidFactory {
52
50
  PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail).forEach(
53
51
  (drillSpec) => {
54
52
  if (
55
- platedDrillKeys.has(
56
- PcbScene3dDrillVoidFactory.#drillKey(drillSpec)
57
- ) ||
53
+ PcbScene3dDrillVoidFactory.#isSlottedDrill(drillSpec) ||
58
54
  edgeDrillKeys.has(
59
55
  PcbScene3dDrillVoidFactory.#drillKey(drillSpec)
60
56
  )
@@ -101,63 +97,6 @@ export class PcbScene3dDrillVoidFactory {
101
97
  )
102
98
  }
103
99
 
104
- /**
105
- * Builds a lookup for drill holes that already have copper barrel geometry.
106
- * @param {{ pads?: any[], vias?: any[] }} detail
107
- * @returns {Set<string>}
108
- */
109
- static #buildPlatedDrillKeySet(detail) {
110
- const platedDrills = new Set()
111
-
112
- for (const via of detail?.vias || []) {
113
- if (PcbScene3dDrillVoidFactory.#hasViaCopperAnnulus(via)) {
114
- platedDrills.add(PcbScene3dDrillVoidFactory.#drillKey(via))
115
- }
116
- }
117
-
118
- for (const pad of detail?.pads || []) {
119
- if (PcbScene3dDrillVoidFactory.#hasPadCopperAnnulus(pad)) {
120
- platedDrills.add(PcbScene3dDrillVoidFactory.#drillKey(pad))
121
- }
122
- }
123
-
124
- return platedDrills
125
- }
126
-
127
- /**
128
- * Checks whether one via has a copper annulus around its drill.
129
- * @param {any} via Via primitive.
130
- * @returns {boolean}
131
- */
132
- static #hasViaCopperAnnulus(via) {
133
- const holeDiameter = Number(via?.holeDiameter || 0)
134
- return (
135
- holeDiameter > 0 &&
136
- Number(via?.diameter || 0) > holeDiameter + 0.001
137
- )
138
- }
139
-
140
- /**
141
- * Checks whether one pad has copper larger than its drill aperture.
142
- * @param {any} pad Pad primitive.
143
- * @returns {boolean}
144
- */
145
- static #hasPadCopperAnnulus(pad) {
146
- const holeDiameter = Number(pad?.holeDiameter || 0)
147
- if (holeDiameter <= 0) {
148
- return false
149
- }
150
-
151
- return [
152
- pad?.sizeTopX,
153
- pad?.sizeTopY,
154
- pad?.sizeMidX,
155
- pad?.sizeMidY,
156
- pad?.sizeBottomX,
157
- pad?.sizeBottomY
158
- ].some((size) => Number(size || 0) > holeDiameter + 0.001)
159
- }
160
-
161
100
  /**
162
101
  * Builds a stable lookup key for one circular drill.
163
102
  * @param {{ x?: number, y?: number, holeDiameter?: number, diameter?: number }} drill
@@ -177,7 +116,7 @@ export class PcbScene3dDrillVoidFactory {
177
116
  * @param {{ color?: number }} options
178
117
  * @returns {any}
179
118
  */
180
- static #buildMaterial(THREE, options) {
119
+ static #buildInteriorMaterial(THREE, options) {
181
120
  return new THREE.MeshStandardMaterial({
182
121
  color: Number.isInteger(options?.color)
183
122
  ? options.color
@@ -188,6 +127,18 @@ export class PcbScene3dDrillVoidFactory {
188
127
  })
189
128
  }
190
129
 
130
+ /**
131
+ * Checks whether one drill is a routed slot.
132
+ * @param {{ diameter?: number, slotLength?: number | null }} drillSpec Drill spec.
133
+ * @returns {boolean}
134
+ */
135
+ static #isSlottedDrill(drillSpec) {
136
+ return (
137
+ Number(drillSpec?.slotLength || 0) >
138
+ Number(drillSpec?.diameter || 0) + 0.001
139
+ )
140
+ }
141
+
191
142
  /**
192
143
  * Builds one open circular drill-interior mesh.
193
144
  * @param {any} THREE
@@ -208,13 +159,6 @@ export class PcbScene3dDrillVoidFactory {
208
159
  material,
209
160
  normalizeBoardPoint
210
161
  ) {
211
- if (
212
- Number(drillSpec?.slotLength || 0) >
213
- Number(drillSpec?.diameter || 0) + 0.001
214
- ) {
215
- return null
216
- }
217
-
218
162
  const point = normalizeBoardPoint(
219
163
  Number(drillSpec?.x || 0),
220
164
  Number(drillSpec?.y || 0)
@@ -281,7 +281,8 @@ export class PcbScene3dExternalModels {
281
281
  return PcbScene3dExternalModels.#buildBoardAssemblyWrapper(
282
282
  THREE,
283
283
  placement,
284
- modelGroup
284
+ modelGroup,
285
+ sceneDescription
285
286
  )
286
287
  }
287
288
 
@@ -371,13 +372,25 @@ export class PcbScene3dExternalModels {
371
372
  * @param {any} THREE
372
373
  * @param {{ positionMil?: { x?: number, y?: number, z?: number }, board?: { widthMil?: number, heightMil?: number, thicknessMil?: number }, sourceFrameScale?: { y?: number } }} placement
373
374
  * @param {any} modelGroup
375
+ * @param {{ sourceFormat?: string } | null | undefined} sceneDescription Scene description.
374
376
  * @returns {any}
375
377
  */
376
- static #buildBoardAssemblyWrapper(THREE, placement, modelGroup) {
378
+ static #buildBoardAssemblyWrapper(
379
+ THREE,
380
+ placement,
381
+ modelGroup,
382
+ sceneDescription
383
+ ) {
377
384
  const wrapperGroup = new THREE.Group()
378
385
  const positionMil = placement?.positionMil || {}
379
386
 
380
- PcbScene3dBoardAssemblyPresentation.apply(modelGroup, placement?.board)
387
+ PcbScene3dBoardAssemblyPresentation.apply(
388
+ modelGroup,
389
+ placement?.board,
390
+ {
391
+ sourceFormat: sceneDescription?.sourceFormat
392
+ }
393
+ )
381
394
  PcbScene3dBoardAssemblyTransform.apply(modelGroup, placement)
382
395
  wrapperGroup.position.set(
383
396
  Number(positionMil.x || 0),
@@ -9,13 +9,16 @@ export class PcbScene3dGeometryZCompressor {
9
9
  * Rewrites mask-covered copper relief below exposed copper height.
10
10
  * @param {any | null} mesh Mesh with a position attribute.
11
11
  * @param {number} sourceCenterZ Original geometry center Z.
12
+ * @param {{ centerOffsetMil?: number }} [options] Compression options.
12
13
  * @returns {void}
13
14
  */
14
- static compressMaskCoveredCopperMesh(mesh, sourceCenterZ) {
15
+ static compressMaskCoveredCopperMesh(mesh, sourceCenterZ, options = {}) {
15
16
  PcbScene3dGeometryZCompressor.compressMesh(
16
17
  mesh,
17
18
  sourceCenterZ,
18
- sourceCenterZ + MASK_COVERED_CENTER_OFFSET_MIL,
19
+ sourceCenterZ +
20
+ MASK_COVERED_CENTER_OFFSET_MIL +
21
+ Number(options?.centerOffsetMil || 0),
19
22
  MASK_COVERED_THICKNESS_MIL
20
23
  )
21
24
  }
@@ -6,7 +6,7 @@ import { PcbScene3dMaterialFinish } from './PcbScene3dMaterialFinish.mjs'
6
6
  export class PcbScene3dMaskCoveredCopperMaterial {
7
7
  static #COPPER_COLOR = 0xd9a61d
8
8
  static #DEFAULT_SOLDER_MASK_COLOR = 0x2a5f27
9
- static #COPPER_BLEND = 0.2
9
+ static #COPPER_BLEND = 0.04
10
10
 
11
11
  /**
12
12
  * Builds a mask-covered copper material.