pcb-scene3d-viewer 1.1.19 → 1.1.21

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,301 @@
1
+ const EPSILON = 1e-9
2
+
3
+ /**
4
+ * Triangulates simple planar polygon faces for tessellated assembly export.
5
+ */
6
+ export class PcbAssemblyPolygonTriangulator {
7
+ /**
8
+ * Triangulates one mesh face without assuming the polygon is convex.
9
+ * @param {number[]} indexes Face vertex indexes.
10
+ * @param {number[][]} vertices Mesh vertices.
11
+ * @returns {number[][]}
12
+ */
13
+ static triangulateFace(indexes, vertices) {
14
+ const projected = PcbAssemblyPolygonTriangulator.#projectFace(
15
+ indexes,
16
+ vertices
17
+ )
18
+ if (!projected.length) {
19
+ return PcbAssemblyPolygonTriangulator.#fanTriangulate(indexes)
20
+ }
21
+
22
+ const nodes = indexes.map((sourceIndex, index) => ({
23
+ sourceIndex,
24
+ x: projected[index][0],
25
+ y: projected[index][1]
26
+ }))
27
+ const signedArea = PcbAssemblyPolygonTriangulator.#signedArea(nodes)
28
+ if (Math.abs(signedArea) <= EPSILON) {
29
+ return PcbAssemblyPolygonTriangulator.#fanTriangulate(indexes)
30
+ }
31
+
32
+ return (
33
+ PcbAssemblyPolygonTriangulator.#earClip(
34
+ signedArea < 0 ? [...nodes].reverse() : [...nodes],
35
+ signedArea < 0
36
+ ) || PcbAssemblyPolygonTriangulator.#fanTriangulate(indexes)
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Ear-clips one counter-clockwise projected polygon.
42
+ * @param {{ sourceIndex: number, x: number, y: number }[]} nodes Polygon nodes.
43
+ * @param {boolean} reverseOutput Whether output must be reversed to match source winding.
44
+ * @returns {number[][] | null}
45
+ */
46
+ static #earClip(nodes, reverseOutput) {
47
+ const remaining = [...nodes]
48
+ const triangles = []
49
+ let guard = remaining.length * remaining.length
50
+
51
+ while (remaining.length > 3 && guard > 0) {
52
+ guard -= 1
53
+ const earIndex = PcbAssemblyPolygonTriangulator.#findEar(remaining)
54
+ if (earIndex < 0) {
55
+ return null
56
+ }
57
+
58
+ triangles.push(
59
+ PcbAssemblyPolygonTriangulator.#triangleAt(
60
+ remaining,
61
+ earIndex,
62
+ reverseOutput
63
+ )
64
+ )
65
+ remaining.splice(earIndex, 1)
66
+ }
67
+
68
+ if (remaining.length === 3) {
69
+ triangles.push(
70
+ PcbAssemblyPolygonTriangulator.#triangleFromNodes(
71
+ remaining[0],
72
+ remaining[1],
73
+ remaining[2],
74
+ reverseOutput
75
+ )
76
+ )
77
+ }
78
+
79
+ return triangles
80
+ }
81
+
82
+ /**
83
+ * Finds the next removable ear.
84
+ * @param {{ sourceIndex: number, x: number, y: number }[]} nodes Polygon nodes.
85
+ * @returns {number}
86
+ */
87
+ static #findEar(nodes) {
88
+ for (let index = 0; index < nodes.length; index += 1) {
89
+ if (PcbAssemblyPolygonTriangulator.#isEar(nodes, index)) {
90
+ return index
91
+ }
92
+ }
93
+
94
+ return -1
95
+ }
96
+
97
+ /**
98
+ * Returns true when the node at index forms a valid ear.
99
+ * @param {{ sourceIndex: number, x: number, y: number }[]} nodes Polygon nodes.
100
+ * @param {number} index Candidate index.
101
+ * @returns {boolean}
102
+ */
103
+ static #isEar(nodes, index) {
104
+ const previous = nodes[(index - 1 + nodes.length) % nodes.length]
105
+ const current = nodes[index]
106
+ const next = nodes[(index + 1) % nodes.length]
107
+
108
+ if (
109
+ PcbAssemblyPolygonTriangulator.#cross2d(previous, current, next) <=
110
+ EPSILON
111
+ ) {
112
+ return false
113
+ }
114
+
115
+ return !nodes.some((node, nodeIndex) => {
116
+ if (
117
+ nodeIndex === index ||
118
+ nodeIndex === (index - 1 + nodes.length) % nodes.length ||
119
+ nodeIndex === (index + 1) % nodes.length
120
+ ) {
121
+ return false
122
+ }
123
+
124
+ return PcbAssemblyPolygonTriangulator.#pointInsideTriangle(
125
+ node,
126
+ previous,
127
+ current,
128
+ next
129
+ )
130
+ })
131
+ }
132
+
133
+ /**
134
+ * Builds the triangle around one ear index.
135
+ * @param {{ sourceIndex: number, x: number, y: number }[]} nodes Polygon nodes.
136
+ * @param {number} index Ear index.
137
+ * @param {boolean} reverseOutput Whether output must be reversed.
138
+ * @returns {number[]}
139
+ */
140
+ static #triangleAt(nodes, index, reverseOutput) {
141
+ return PcbAssemblyPolygonTriangulator.#triangleFromNodes(
142
+ nodes[(index - 1 + nodes.length) % nodes.length],
143
+ nodes[index],
144
+ nodes[(index + 1) % nodes.length],
145
+ reverseOutput
146
+ )
147
+ }
148
+
149
+ /**
150
+ * Builds one source-index triangle.
151
+ * @param {{ sourceIndex: number }} first First node.
152
+ * @param {{ sourceIndex: number }} second Second node.
153
+ * @param {{ sourceIndex: number }} third Third node.
154
+ * @param {boolean} reverseOutput Whether output must be reversed.
155
+ * @returns {number[]}
156
+ */
157
+ static #triangleFromNodes(first, second, third, reverseOutput) {
158
+ return reverseOutput
159
+ ? [first.sourceIndex, third.sourceIndex, second.sourceIndex]
160
+ : [first.sourceIndex, second.sourceIndex, third.sourceIndex]
161
+ }
162
+
163
+ /**
164
+ * Projects a 3D face onto its dominant 2D plane.
165
+ * @param {number[]} indexes Face vertex indexes.
166
+ * @param {number[][]} vertices Mesh vertices.
167
+ * @returns {number[][]}
168
+ */
169
+ static #projectFace(indexes, vertices) {
170
+ const points = indexes
171
+ .map((index) => vertices[index])
172
+ .filter((point) => Array.isArray(point) && point.length >= 3)
173
+ const normal = PcbAssemblyPolygonTriangulator.#newellNormal(points)
174
+ const dominantAxis =
175
+ Math.abs(normal[0]) >= Math.abs(normal[1]) &&
176
+ Math.abs(normal[0]) >= Math.abs(normal[2])
177
+ ? 0
178
+ : Math.abs(normal[1]) >= Math.abs(normal[2])
179
+ ? 1
180
+ : 2
181
+
182
+ if (
183
+ Math.abs(normal[0]) <= EPSILON &&
184
+ Math.abs(normal[1]) <= EPSILON &&
185
+ Math.abs(normal[2]) <= EPSILON
186
+ ) {
187
+ return []
188
+ }
189
+
190
+ return points.map((point) =>
191
+ dominantAxis === 0
192
+ ? [Number(point[1] || 0), Number(point[2] || 0)]
193
+ : dominantAxis === 1
194
+ ? [Number(point[0] || 0), Number(point[2] || 0)]
195
+ : [Number(point[0] || 0), Number(point[1] || 0)]
196
+ )
197
+ }
198
+
199
+ /**
200
+ * Computes a polygon normal with Newell's method.
201
+ * @param {number[][]} points Face points.
202
+ * @returns {number[]}
203
+ */
204
+ static #newellNormal(points) {
205
+ const normal = [0, 0, 0]
206
+
207
+ for (let index = 0; index < points.length; index += 1) {
208
+ const current = points[index]
209
+ const next = points[(index + 1) % points.length]
210
+ normal[0] +=
211
+ (Number(current[1] || 0) - Number(next[1] || 0)) *
212
+ (Number(current[2] || 0) + Number(next[2] || 0))
213
+ normal[1] +=
214
+ (Number(current[2] || 0) - Number(next[2] || 0)) *
215
+ (Number(current[0] || 0) + Number(next[0] || 0))
216
+ normal[2] +=
217
+ (Number(current[0] || 0) - Number(next[0] || 0)) *
218
+ (Number(current[1] || 0) + Number(next[1] || 0))
219
+ }
220
+
221
+ return normal
222
+ }
223
+
224
+ /**
225
+ * Computes signed polygon area in projected coordinates.
226
+ * @param {{ x: number, y: number }[]} nodes Polygon nodes.
227
+ * @returns {number}
228
+ */
229
+ static #signedArea(nodes) {
230
+ let area = 0
231
+
232
+ for (let index = 0; index < nodes.length; index += 1) {
233
+ const current = nodes[index]
234
+ const next = nodes[(index + 1) % nodes.length]
235
+ area += current.x * next.y - next.x * current.y
236
+ }
237
+
238
+ return area / 2
239
+ }
240
+
241
+ /**
242
+ * Computes the 2D cross product for three points.
243
+ * @param {{ x: number, y: number }} first First point.
244
+ * @param {{ x: number, y: number }} second Second point.
245
+ * @param {{ x: number, y: number }} third Third point.
246
+ * @returns {number}
247
+ */
248
+ static #cross2d(first, second, third) {
249
+ return (
250
+ (second.x - first.x) * (third.y - first.y) -
251
+ (second.y - first.y) * (third.x - first.x)
252
+ )
253
+ }
254
+
255
+ /**
256
+ * Returns true when a point lies strictly inside a triangle.
257
+ * @param {{ x: number, y: number }} point Candidate point.
258
+ * @param {{ x: number, y: number }} first First triangle point.
259
+ * @param {{ x: number, y: number }} second Second triangle point.
260
+ * @param {{ x: number, y: number }} third Third triangle point.
261
+ * @returns {boolean}
262
+ */
263
+ static #pointInsideTriangle(point, first, second, third) {
264
+ const firstCross = PcbAssemblyPolygonTriangulator.#cross2d(
265
+ first,
266
+ second,
267
+ point
268
+ )
269
+ const secondCross = PcbAssemblyPolygonTriangulator.#cross2d(
270
+ second,
271
+ third,
272
+ point
273
+ )
274
+ const thirdCross = PcbAssemblyPolygonTriangulator.#cross2d(
275
+ third,
276
+ first,
277
+ point
278
+ )
279
+
280
+ return (
281
+ firstCross > EPSILON &&
282
+ secondCross > EPSILON &&
283
+ thirdCross > EPSILON
284
+ )
285
+ }
286
+
287
+ /**
288
+ * Falls back to simple fan triangles for degenerate faces.
289
+ * @param {number[]} indexes Face vertex indexes.
290
+ * @returns {number[][]}
291
+ */
292
+ static #fanTriangulate(indexes) {
293
+ const triangles = []
294
+
295
+ for (let index = 1; index < indexes.length - 1; index += 1) {
296
+ triangles.push([indexes[0], indexes[index], indexes[index + 1]])
297
+ }
298
+
299
+ return triangles
300
+ }
301
+ }