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.
- package/package.json +2 -1
- package/src/PcbScene3dCopperDetailGroupBuilder.mjs +16 -0
- package/src/PcbScene3dCopperFactory.mjs +64 -93
- package/src/PcbScene3dCopperFillAreaClipper.mjs +726 -0
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +530 -8
- package/src/PcbScene3dCopperFillPolygonBoolean.mjs +235 -0
- package/src/PcbScene3dCopperPrismBuilder.mjs +92 -0
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +2 -1
- package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +64 -0
|
@@ -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
|
+
}
|