pcb-scene3d-viewer 1.1.21 → 1.1.31

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.
Files changed (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -1,3 +1,5 @@
1
+ import earcut from 'earcut'
2
+
1
3
  const FULL_CIRCLE_DEGREES = 360
2
4
 
3
5
  /**
@@ -88,6 +90,100 @@ export class PcbAssemblyMeshUtils {
88
90
  }
89
91
  }
90
92
 
93
+ /**
94
+ * Builds an extruded polygon mesh with inner cutout loops.
95
+ * @param {string} name Mesh name.
96
+ * @param {number[][]} outerPoints Outer polygon points in mils.
97
+ * @param {number[][][]} holeLoops Inner cutout loops in mils.
98
+ * @param {number} z Center Z in mils.
99
+ * @param {number} thickness Extrusion thickness in mils.
100
+ * @param {number[] | undefined} color Optional RGB color.
101
+ * @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] } | null}
102
+ */
103
+ static prismWithHoles(
104
+ name,
105
+ outerPoints,
106
+ holeLoops,
107
+ z,
108
+ thickness,
109
+ color = undefined
110
+ ) {
111
+ const outer = PcbAssemblyMeshUtils.cleanLoop(outerPoints)
112
+ const holes = (Array.isArray(holeLoops) ? holeLoops : [])
113
+ .map((loop) => PcbAssemblyMeshUtils.cleanLoop(loop))
114
+ .filter((loop) => loop.length >= 3)
115
+ if (outer.length < 3) {
116
+ return null
117
+ }
118
+ if (!holes.length) {
119
+ return PcbAssemblyMeshUtils.prism(name, outer, z, thickness, color)
120
+ }
121
+
122
+ const loops = [outer, ...holes]
123
+ const holeIndexes = []
124
+ const points = []
125
+ const flat = []
126
+ for (const loop of loops) {
127
+ if (points.length) {
128
+ holeIndexes.push(points.length)
129
+ }
130
+ for (const point of loop) {
131
+ points.push(point)
132
+ flat.push(point[0], point[1])
133
+ }
134
+ }
135
+
136
+ const triangles = earcut(flat, holeIndexes, 2)
137
+ if (!triangles.length) {
138
+ return PcbAssemblyMeshUtils.prism(name, outer, z, thickness, color)
139
+ }
140
+
141
+ const halfThickness = Math.max(Number(thickness || 0), 0.001) / 2
142
+ const bottomZ = Number(z || 0) - halfThickness
143
+ const topZ = Number(z || 0) + halfThickness
144
+ const topOffset = points.length
145
+ const vertices = [
146
+ ...points.map((point) => [point[0], point[1], bottomZ]),
147
+ ...points.map((point) => [point[0], point[1], topZ])
148
+ ]
149
+ const faces = []
150
+
151
+ for (let index = 0; index + 2 < triangles.length; index += 3) {
152
+ const a = triangles[index]
153
+ const b = triangles[index + 1]
154
+ const c = triangles[index + 2]
155
+ faces.push([c, b, a])
156
+ faces.push([a + topOffset, b + topOffset, c + topOffset])
157
+ }
158
+
159
+ let loopStart = 0
160
+ PcbAssemblyMeshUtils.#appendLoopWalls(
161
+ faces,
162
+ loopStart,
163
+ outer.length,
164
+ topOffset,
165
+ false
166
+ )
167
+ loopStart += outer.length
168
+ for (const hole of holes) {
169
+ PcbAssemblyMeshUtils.#appendLoopWalls(
170
+ faces,
171
+ loopStart,
172
+ hole.length,
173
+ topOffset,
174
+ true
175
+ )
176
+ loopStart += hole.length
177
+ }
178
+
179
+ return {
180
+ name,
181
+ vertices,
182
+ faces,
183
+ ...(Array.isArray(color) ? { color } : {})
184
+ }
185
+ }
186
+
91
187
  /**
92
188
  * Builds a cylinder mesh from polygonal rings.
93
189
  * @param {string} name Mesh name.
@@ -538,4 +634,25 @@ export class PcbAssemblyMeshUtils {
538
634
  .replace(/^-+|-+$/gu, '')
539
635
  .slice(0, 80)
540
636
  }
637
+
638
+ /**
639
+ * Appends side-wall faces for one loop.
640
+ * @param {number[][]} faces Mutable face list.
641
+ * @param {number} start Loop start index.
642
+ * @param {number} length Loop length.
643
+ * @param {number} topOffset Top vertex offset.
644
+ * @param {boolean} reverse Whether to reverse wall orientation.
645
+ * @returns {void}
646
+ */
647
+ static #appendLoopWalls(faces, start, length, topOffset, reverse) {
648
+ for (let index = 0; index < length; index += 1) {
649
+ const current = start + index
650
+ const next = start + ((index + 1) % length)
651
+ faces.push(
652
+ reverse
653
+ ? [next, current, current + topOffset, next + topOffset]
654
+ : [current, next, next + topOffset, current + topOffset]
655
+ )
656
+ }
657
+ }
541
658
  }
@@ -1,3 +1,5 @@
1
+ import { PcbAssemblyGltfModelMeshParser } from './PcbAssemblyGltfModelMeshParser.mjs'
2
+ import { PcbAssemblyTextModelMeshParser } from './PcbAssemblyTextModelMeshParser.mjs'
1
3
  import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
2
4
 
3
5
  /**
@@ -31,6 +33,22 @@ export class PcbAssemblyModelMeshLoader {
31
33
  return this.#loadWrlMeshes(model)
32
34
  }
33
35
 
36
+ if (format === 'stl') {
37
+ return await PcbAssemblyTextModelMeshParser.parseStlModel(model)
38
+ }
39
+
40
+ if (format === 'obj') {
41
+ return await PcbAssemblyTextModelMeshParser.parseObjModel(model)
42
+ }
43
+
44
+ if (format === 'gltf') {
45
+ return await PcbAssemblyGltfModelMeshParser.parseGltfModel(model)
46
+ }
47
+
48
+ if (format === 'glb') {
49
+ return await PcbAssemblyGltfModelMeshParser.parseGlbModel(model)
50
+ }
51
+
34
52
  throw new Error('Unsupported model format: ' + (format || 'unknown'))
35
53
  }
36
54
 
@@ -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.