pcb-scene3d-viewer 1.1.48 → 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
+ }
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dGeometryZCompressor } from './PcbScene3dGeometryZCompressor.mjs'
2
+ import { PcbScene3dMaskCoveredCopperSurfaceFilter } from './PcbScene3dMaskCoveredCopperSurfaceFilter.mjs'
2
3
 
3
4
  /**
4
5
  * Builds side-specific copper relief groups that sit below solder mask.
@@ -98,10 +99,10 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
98
99
  if (!mesh) {
99
100
  return
100
101
  }
101
-
102
102
  PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(mesh, z, {
103
103
  centerOffsetMil: options.centerOffsetMil
104
104
  })
105
+ PcbScene3dMaskCoveredCopperSurfaceFilter.keepOuterSurface(mesh)
105
106
  mesh.name = name
106
107
  mesh.renderOrder = Number(options.renderOrder || 0)
107
108
  PcbScene3dMaskCoveredCopperSideGroupBuilder.#applyCopperTint(
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Keeps only the outward-facing surface of solder-mask-covered copper relief.
3
+ */
4
+ export class PcbScene3dMaskCoveredCopperSurfaceFilter {
5
+ static #Z_EPSILON = 0.001
6
+
7
+ /**
8
+ * Removes hidden underside and side-wall triangles from one mesh.
9
+ * @param {any | null} mesh Copper relief mesh.
10
+ * @returns {void}
11
+ */
12
+ static keepOuterSurface(mesh) {
13
+ const geometry = mesh?.geometry
14
+ const position = geometry?.getAttribute?.('position')
15
+ const source = position?.array
16
+ if (!source || position.itemSize !== 3) {
17
+ return
18
+ }
19
+
20
+ const maxZ = PcbScene3dMaskCoveredCopperSurfaceFilter.#maxZ(source)
21
+ const filtered = []
22
+ for (let index = 0; index + 8 < source.length; index += 9) {
23
+ if (
24
+ [2, 5, 8].every(
25
+ (offset) =>
26
+ Math.abs(source[index + offset] - maxZ) <=
27
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#Z_EPSILON
28
+ )
29
+ ) {
30
+ filtered.push(...source.slice(index, index + 9))
31
+ }
32
+ }
33
+
34
+ if (!filtered.length || filtered.length === source.length) {
35
+ return
36
+ }
37
+
38
+ geometry.setAttribute(
39
+ 'position',
40
+ new position.constructor(
41
+ filtered,
42
+ position.itemSize,
43
+ position.normalized
44
+ )
45
+ )
46
+ geometry.deleteAttribute?.('normal')
47
+ geometry.computeVertexNormals?.()
48
+ geometry.computeBoundingBox?.()
49
+ geometry.computeBoundingSphere?.()
50
+ }
51
+
52
+ /**
53
+ * Resolves the highest Z plane in one packed XYZ buffer.
54
+ * @param {ArrayLike<number>} source Position buffer.
55
+ * @returns {number}
56
+ */
57
+ static #maxZ(source) {
58
+ let maxZ = -Infinity
59
+ for (let index = 2; index < source.length; index += 3) {
60
+ maxZ = Math.max(maxZ, Number(source[index]))
61
+ }
62
+ return maxZ
63
+ }
64
+ }