pcb-scene3d-viewer 1.1.21 → 1.1.22

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,394 @@
1
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
2
+
3
+ const GEOMETRY_EPSILON = 0.001
4
+
5
+ /**
6
+ * Builds PCB assembly copper pad meshes.
7
+ */
8
+ export class PcbAssemblyPadMeshBuilder {
9
+ /**
10
+ * Builds one pad mesh for an assembly export.
11
+ * @param {string} name Mesh name.
12
+ * @param {object} pad Pad primitive.
13
+ * @param {'top' | 'bottom'} side Pad side.
14
+ * @param {number} z Center Z.
15
+ * @param {number} thickness Extrusion thickness.
16
+ * @param {number[]} color Mesh color.
17
+ * @returns {object | null}
18
+ */
19
+ static build(name, pad, side, z, thickness, color) {
20
+ const size = PcbAssemblyPadMeshBuilder.#padSize(pad, side)
21
+ if (!size) {
22
+ return null
23
+ }
24
+
25
+ const offset = PcbAssemblyPadMeshBuilder.#padOffset(pad, side)
26
+ const x = Number(pad?.x || 0) + offset.x
27
+ const y = Number(pad?.y || 0) + offset.y
28
+ const shape = PcbAssemblyPadMeshBuilder.#padShape(pad, side)
29
+ const isCircle =
30
+ Math.abs(size.width - size.height) < GEOMETRY_EPSILON && shape !== 2
31
+ const hole = PcbAssemblyPadMeshBuilder.#holeLoop(pad, x, y)
32
+ const mesh = hole
33
+ ? PcbAssemblyPadMeshBuilder.#drilledMesh(
34
+ name,
35
+ x,
36
+ y,
37
+ size,
38
+ isCircle,
39
+ hole,
40
+ z,
41
+ thickness,
42
+ color
43
+ )
44
+ : PcbAssemblyPadMeshBuilder.#solidMesh(
45
+ name,
46
+ x,
47
+ y,
48
+ size,
49
+ isCircle,
50
+ z,
51
+ thickness,
52
+ color
53
+ )
54
+
55
+ return PcbAssemblyPadMeshBuilder.#rotateMeshAroundZ(
56
+ mesh,
57
+ Number(pad?.rotation || 0),
58
+ x,
59
+ y
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Builds one solid pad mesh.
65
+ * @param {string} name Mesh name.
66
+ * @param {number} x Pad center X.
67
+ * @param {number} y Pad center Y.
68
+ * @param {{ width: number, height: number }} size Pad size.
69
+ * @param {boolean} isCircle Whether the pad is circular.
70
+ * @param {number} z Center Z.
71
+ * @param {number} thickness Extrusion thickness.
72
+ * @param {number[]} color Mesh color.
73
+ * @returns {object}
74
+ */
75
+ static #solidMesh(name, x, y, size, isCircle, z, thickness, color) {
76
+ return isCircle
77
+ ? PcbAssemblyMeshUtils.cylinder(name, {
78
+ x,
79
+ y,
80
+ z,
81
+ radius: size.width / 2,
82
+ height: thickness,
83
+ color
84
+ })
85
+ : PcbAssemblyMeshUtils.box(name, {
86
+ x,
87
+ y,
88
+ z,
89
+ width: size.width,
90
+ depth: size.height,
91
+ height: thickness,
92
+ color
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Builds one drilled pad mesh.
98
+ * @param {string} name Mesh name.
99
+ * @param {number} x Pad center X.
100
+ * @param {number} y Pad center Y.
101
+ * @param {{ width: number, height: number }} size Pad size.
102
+ * @param {boolean} isCircle Whether the pad is circular.
103
+ * @param {number[][]} hole Hole loop.
104
+ * @param {number} z Center Z.
105
+ * @param {number} thickness Extrusion thickness.
106
+ * @param {number[]} color Mesh color.
107
+ * @returns {object | null}
108
+ */
109
+ static #drilledMesh(name, x, y, size, isCircle, hole, z, thickness, color) {
110
+ if (
111
+ PcbAssemblyPadMeshBuilder.#holeConsumesPad(
112
+ x,
113
+ y,
114
+ size,
115
+ isCircle,
116
+ hole
117
+ )
118
+ ) {
119
+ return null
120
+ }
121
+
122
+ const outer = isCircle
123
+ ? PcbAssemblyMeshUtils.circlePoints(x, y, size.width / 2, 32)
124
+ : PcbAssemblyPadMeshBuilder.#rectanglePoints(x, y, size)
125
+
126
+ return PcbAssemblyMeshUtils.prismWithHoles(
127
+ name,
128
+ outer,
129
+ [hole],
130
+ z,
131
+ thickness,
132
+ color
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Returns true when a drill leaves no meaningful copper surface.
138
+ * @param {number} x Pad center X.
139
+ * @param {number} y Pad center Y.
140
+ * @param {{ width: number, height: number }} size Pad size.
141
+ * @param {boolean} isCircle Whether the pad is circular.
142
+ * @param {number[][]} hole Hole loop.
143
+ * @returns {boolean}
144
+ */
145
+ static #holeConsumesPad(x, y, size, isCircle, hole) {
146
+ if (isCircle) {
147
+ const outerRadius = Math.min(size.width, size.height) / 2
148
+ const innerRadius = hole.reduce(
149
+ (minRadius, point) =>
150
+ Math.min(minRadius, Math.hypot(point[0] - x, point[1] - y)),
151
+ Infinity
152
+ )
153
+ return innerRadius >= outerRadius - GEOMETRY_EPSILON
154
+ }
155
+
156
+ const outerArea = Math.max(size.width, 0) * Math.max(size.height, 0)
157
+ const holeArea = Math.abs(PcbAssemblyPadMeshBuilder.#polygonArea(hole))
158
+ return holeArea >= outerArea - GEOMETRY_EPSILON
159
+ }
160
+
161
+ /**
162
+ * Builds a drill loop when a pad has a usable hole.
163
+ * @param {object} pad Pad primitive.
164
+ * @param {number} x Pad center X.
165
+ * @param {number} y Pad center Y.
166
+ * @returns {number[][] | null}
167
+ */
168
+ static #holeLoop(pad, x, y) {
169
+ const diameter = PcbAssemblyPadMeshBuilder.#firstPositive([
170
+ pad?.holeDiameter,
171
+ pad?.drillDiameter,
172
+ pad?.holeSize,
173
+ pad?.drill,
174
+ pad?.holeGeometry?.diameter,
175
+ pad?.holeGeometry?.width
176
+ ])
177
+ if (!diameter) {
178
+ return null
179
+ }
180
+
181
+ const slotLength = PcbAssemblyPadMeshBuilder.#firstPositive([
182
+ pad?.holeSlotLength,
183
+ pad?.slotLength,
184
+ pad?.holeGeometry?.slotLength,
185
+ pad?.holeGeometry?.length
186
+ ])
187
+ if (slotLength > diameter + GEOMETRY_EPSILON) {
188
+ return PcbAssemblyPadMeshBuilder.#rotatedPoints(
189
+ PcbAssemblyMeshUtils.capsulePoints(
190
+ x - (slotLength - diameter) / 2,
191
+ y,
192
+ x + (slotLength - diameter) / 2,
193
+ y,
194
+ diameter / 2
195
+ ),
196
+ x,
197
+ y,
198
+ Number(pad?.holeRotation ?? pad?.rotation ?? 0) -
199
+ Number(pad?.rotation || 0)
200
+ )
201
+ }
202
+
203
+ return PcbAssemblyMeshUtils.circlePoints(x, y, diameter / 2, 24)
204
+ }
205
+
206
+ /**
207
+ * Resolves side-specific pad dimensions.
208
+ * @param {object} pad Pad primitive.
209
+ * @param {'top' | 'bottom'} side Pad side.
210
+ * @returns {{ width: number, height: number } | null}
211
+ */
212
+ static #padSize(pad, side) {
213
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
214
+ let width = PcbAssemblyPadMeshBuilder.#firstPositive([
215
+ pad?.['size' + prefix + 'X']
216
+ ])
217
+ let height = PcbAssemblyPadMeshBuilder.#firstPositive([
218
+ pad?.['size' + prefix + 'Y']
219
+ ])
220
+
221
+ if (side === 'top' && !width) {
222
+ width = PcbAssemblyPadMeshBuilder.#firstPositive([
223
+ pad?.width,
224
+ pad?.sizeX,
225
+ pad?.diameter,
226
+ pad?.sizeMidX,
227
+ pad?.sizeBottomX
228
+ ])
229
+ }
230
+ if (side === 'top' && !height) {
231
+ height = PcbAssemblyPadMeshBuilder.#firstPositive([
232
+ pad?.height,
233
+ pad?.sizeY,
234
+ pad?.diameter,
235
+ pad?.sizeMidY,
236
+ pad?.sizeBottomY,
237
+ width
238
+ ])
239
+ }
240
+ if (width && !height) {
241
+ height = width
242
+ }
243
+
244
+ return width && height
245
+ ? {
246
+ width: Math.max(width, 1),
247
+ height: Math.max(height, 1)
248
+ }
249
+ : null
250
+ }
251
+
252
+ /**
253
+ * Resolves side-specific pad center offset.
254
+ * @param {object} pad Pad primitive.
255
+ * @param {'top' | 'bottom'} side Pad side.
256
+ * @returns {{ x: number, y: number }}
257
+ */
258
+ static #padOffset(pad, side) {
259
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
260
+
261
+ return {
262
+ x: Number(pad?.['offset' + prefix + 'X'] || 0),
263
+ y: Number(pad?.['offset' + prefix + 'Y'] || 0)
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Resolves the renderer pad shape code for one side.
269
+ * @param {object} pad Pad primitive.
270
+ * @param {'top' | 'bottom'} side Pad side.
271
+ * @returns {number}
272
+ */
273
+ static #padShape(pad, side) {
274
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
275
+ const rounded = pad?.['roundedRectShape' + prefix]
276
+ if (Number.isInteger(Number(rounded))) {
277
+ return Number(rounded)
278
+ }
279
+
280
+ return Number(
281
+ pad?.['shape' + prefix] ||
282
+ pad?.shapeMid ||
283
+ (side === 'top' ? pad?.shapeBottom : pad?.shapeTop) ||
284
+ 0
285
+ )
286
+ }
287
+
288
+ /**
289
+ * Builds rectangle points for an axis-aligned pad.
290
+ * @param {number} x Center X.
291
+ * @param {number} y Center Y.
292
+ * @param {{ width: number, height: number }} size Pad size.
293
+ * @returns {number[][]}
294
+ */
295
+ static #rectanglePoints(x, y, size) {
296
+ const halfWidth = size.width / 2
297
+ const halfHeight = size.height / 2
298
+ return [
299
+ [x - halfWidth, y - halfHeight],
300
+ [x + halfWidth, y - halfHeight],
301
+ [x + halfWidth, y + halfHeight],
302
+ [x - halfWidth, y + halfHeight]
303
+ ]
304
+ }
305
+
306
+ /**
307
+ * Rotates points around a center.
308
+ * @param {number[][]} points Source points.
309
+ * @param {number} centerX Center X.
310
+ * @param {number} centerY Center Y.
311
+ * @param {number} rotationDeg Rotation in degrees.
312
+ * @returns {number[][]}
313
+ */
314
+ static #rotatedPoints(points, centerX, centerY, rotationDeg) {
315
+ if (Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
316
+ return points
317
+ }
318
+
319
+ const angle = (Number(rotationDeg || 0) * Math.PI) / 180
320
+ const cos = Math.cos(angle)
321
+ const sin = Math.sin(angle)
322
+ return points.map((point) => {
323
+ const dx = point[0] - centerX
324
+ const dy = point[1] - centerY
325
+ return [
326
+ centerX + dx * cos - dy * sin,
327
+ centerY + dx * sin + dy * cos
328
+ ]
329
+ })
330
+ }
331
+
332
+ /**
333
+ * Calculates polygon area using the shoelace formula.
334
+ * @param {number[][]} points Polygon points.
335
+ * @returns {number}
336
+ */
337
+ static #polygonArea(points) {
338
+ let area = 0
339
+ for (let index = 0; index < points.length; index += 1) {
340
+ const point = points[index]
341
+ const next = points[(index + 1) % points.length]
342
+ area += point[0] * next[1] - next[0] * point[1]
343
+ }
344
+
345
+ return area / 2
346
+ }
347
+
348
+ /**
349
+ * Rotates one mesh around a Z-axis origin.
350
+ * @param {object | null} mesh Mesh to rotate.
351
+ * @param {number} rotationDeg Rotation angle.
352
+ * @param {number} originX Origin X.
353
+ * @param {number} originY Origin Y.
354
+ * @returns {object | null}
355
+ */
356
+ static #rotateMeshAroundZ(mesh, rotationDeg, originX, originY) {
357
+ if (!mesh || Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
358
+ return mesh
359
+ }
360
+
361
+ const rotationRad = (Number(rotationDeg || 0) * Math.PI) / 180
362
+ const cos = Math.cos(rotationRad)
363
+ const sin = Math.sin(rotationRad)
364
+
365
+ return {
366
+ ...mesh,
367
+ vertices: mesh.vertices.map((vertex) => {
368
+ const dx = Number(vertex[0] || 0) - originX
369
+ const dy = Number(vertex[1] || 0) - originY
370
+ return [
371
+ originX + dx * cos - dy * sin,
372
+ originY + dx * sin + dy * cos,
373
+ Number(vertex[2] || 0)
374
+ ]
375
+ })
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Returns the first positive finite number.
381
+ * @param {unknown[]} values Candidate values.
382
+ * @returns {number}
383
+ */
384
+ static #firstPositive(values) {
385
+ for (const value of values) {
386
+ const number = Number(value)
387
+ if (Number.isFinite(number) && number > 0) {
388
+ return number
389
+ }
390
+ }
391
+
392
+ return 0
393
+ }
394
+ }
@@ -739,17 +739,39 @@ export class PcbAssemblyStepWriter {
739
739
  }
740
740
 
741
741
  /**
742
- * Returns clamped RGB components for one mesh color.
742
+ * Returns display-space RGB components for one mesh color.
743
743
  * @param {number[] | undefined} color Mesh RGB color.
744
744
  * @returns {number[]}
745
745
  */
746
746
  static #colorComponents(color) {
747
747
  const normalized = Array.isArray(color) ? color : [0.55, 0.56, 0.58]
748
748
  return [0, 1, 2].map((index) =>
749
- Math.max(Math.min(Number(normalized[index] || 0), 1), 0)
749
+ PcbAssemblyStepWriter.#linearToSrgb(
750
+ PcbAssemblyStepWriter.#clampColorChannel(normalized[index])
751
+ )
750
752
  )
751
753
  }
752
754
 
755
+ /**
756
+ * Clamps one color channel to the STEP RGB range.
757
+ * @param {unknown} value Candidate color channel.
758
+ * @returns {number}
759
+ */
760
+ static #clampColorChannel(value) {
761
+ return Math.max(Math.min(Number(value || 0), 1), 0)
762
+ }
763
+
764
+ /**
765
+ * Converts one linear RGB channel to display-space sRGB.
766
+ * @param {number} value Linear RGB channel.
767
+ * @returns {number}
768
+ */
769
+ static #linearToSrgb(value) {
770
+ return value <= 0.0031308
771
+ ? value * 12.92
772
+ : 1.055 * Math.pow(value, 1 / 2.4) - 0.055
773
+ }
774
+
753
775
  /**
754
776
  * Tests whether a vertex has finite coordinates.
755
777
  * @param {unknown} vertex Candidate vertex.