pcb-scene3d-viewer 1.1.2 → 1.1.11

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/PcbScene3dArcUtils.mjs +18 -0
  3. package/src/PcbScene3dBoardAssemblyPresentation.mjs +3 -0
  4. package/src/PcbScene3dBoardSolderMaskFactory.mjs +2 -2
  5. package/src/PcbScene3dCameraRig.mjs +16 -1
  6. package/src/PcbScene3dCopperDetailFilter.mjs +73 -1
  7. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +482 -0
  8. package/src/PcbScene3dCopperFactory.mjs +499 -85
  9. package/src/PcbScene3dCopperLayerFilter.mjs +48 -0
  10. package/src/PcbScene3dCopperOcclusionClipper.mjs +86 -0
  11. package/src/PcbScene3dCopperTextFactory.mjs +26 -6
  12. package/src/PcbScene3dCutoutGeometryFilter.mjs +25 -28
  13. package/src/PcbScene3dDrillCutoutFilter.mjs +123 -0
  14. package/src/PcbScene3dGeometryFaceRefiner.mjs +218 -0
  15. package/src/PcbScene3dGeometryZCompressor.mjs +48 -0
  16. package/src/PcbScene3dMaskCoveredCopperMaterial.mjs +72 -0
  17. package/src/PcbScene3dMaterialFinish.mjs +49 -0
  18. package/src/PcbScene3dPadFactory.mjs +160 -3
  19. package/src/PcbScene3dPadSurfaceStack.mjs +155 -0
  20. package/src/PcbScene3dPolygonOverlap.mjs +283 -0
  21. package/src/PcbScene3dRuntime.mjs +11 -64
  22. package/src/PcbScene3dRuntimeBoardMeshes.mjs +8 -3
  23. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +212 -0
  24. package/src/PcbScene3dShapeHoleMerger.mjs +476 -0
  25. package/src/PcbScene3dSilkscreenFactory.mjs +193 -126
  26. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +241 -0
  27. package/src/PcbScene3dStrokeGeometryBuilder.mjs +7 -6
  28. package/src/PcbScene3dTerminalCutoutClassifier.mjs +92 -0
  29. package/src/PcbScene3dTrueTypeTextFactory.mjs +16 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.1.2",
3
+ "version": "1.1.11",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -4,6 +4,24 @@
4
4
  export class PcbScene3dArcUtils {
5
5
  static #FULL_CIRCLE_EPSILON = 0.001
6
6
 
7
+ /**
8
+ * Resolves one arc record's sweep, preserving explicit long-arc sweeps.
9
+ * @param {{ startAngle?: number, endAngle?: number, sweepAngle?: number }} arc
10
+ * Arc record.
11
+ * @returns {number}
12
+ */
13
+ static resolveArcSweepDelta(arc) {
14
+ const sweepAngle = Number(arc?.sweepAngle)
15
+ if (Number.isFinite(sweepAngle)) {
16
+ return sweepAngle
17
+ }
18
+
19
+ return PcbScene3dArcUtils.resolveSweepDelta(
20
+ Number(arc?.startAngle || 0),
21
+ Number(arc?.endAngle || 0)
22
+ )
23
+ }
24
+
7
25
  /**
8
26
  * Normalizes one PCB arc delta to the intended short wrapped sweep.
9
27
  * @param {number} startAngle Start angle in degrees.
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+ import { PcbScene3dMaterialFinish } from './PcbScene3dMaterialFinish.mjs'
2
3
 
3
4
  /**
4
5
  * Applies display-only material adjustments to full-board assembly models.
@@ -155,6 +156,7 @@ export class PcbScene3dBoardAssemblyPresentation {
155
156
  object?.material,
156
157
  surfaceColor
157
158
  )
159
+ PcbScene3dMaterialFinish.applySemiMatteSolderMask(object?.material)
158
160
  }
159
161
 
160
162
  /**
@@ -192,6 +194,7 @@ export class PcbScene3dBoardAssemblyPresentation {
192
194
  surfaceMaterial,
193
195
  surfaceColor
194
196
  )
197
+ PcbScene3dMaterialFinish.applySemiMatteSolderMask(surfaceMaterial)
195
198
  PcbScene3dBoardAssemblyPresentation.#applyMaterialColor(
196
199
  edgeMaterial,
197
200
  edgeColor
@@ -1,6 +1,7 @@
1
1
  import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
2
  import { PcbScene3dCutoutGeometryFilter } from './PcbScene3dCutoutGeometryFilter.mjs'
3
3
  import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
4
+ import { PcbScene3dMaterialFinish } from './PcbScene3dMaterialFinish.mjs'
4
5
  import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
5
6
 
6
7
  /**
@@ -602,8 +603,7 @@ export class PcbScene3dBoardSolderMaskFactory {
602
603
  color: PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
603
604
  hasBoardAssemblyModel: true
604
605
  }),
605
- roughness: 0.68,
606
- metalness: 0.08,
606
+ ...PcbScene3dMaterialFinish.semiMatteSolderMaskProperties(),
607
607
  polygonOffset: true,
608
608
  polygonOffsetFactor: -1,
609
609
  polygonOffsetUnits: -1,
@@ -48,7 +48,7 @@ export class PcbScene3dCameraRig {
48
48
  return {
49
49
  radius,
50
50
  target,
51
- up: { x: 0, y: -1, z: 0 },
51
+ up: PcbScene3dCameraRig.#bottomUpVector(sceneDescription),
52
52
  position: {
53
53
  x: target.x,
54
54
  y: target.y,
@@ -165,4 +165,19 @@ export class PcbScene3dCameraRig {
165
165
  z: Number(controls?.target?.z || 0)
166
166
  }
167
167
  }
168
+
169
+ /**
170
+ * Resolves screen-up for bottom views from the scene coordinate contract.
171
+ * @param {{ coordinateSystem?: string }} sceneDescription Scene metadata.
172
+ * @returns {{ x: number, y: number, z: number }}
173
+ */
174
+ static #bottomUpVector(sceneDescription) {
175
+ const coordinateSystem = String(
176
+ sceneDescription?.coordinateSystem || ''
177
+ ).toLowerCase()
178
+
179
+ return coordinateSystem === 'kicad-3d-y-up'
180
+ ? { x: 0, y: 1, z: 0 }
181
+ : { x: 0, y: -1, z: 0 }
182
+ }
168
183
  }
@@ -39,6 +39,41 @@ export class PcbScene3dCopperDetailFilter {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Resolves trace-like copper that should be visible through solder mask.
44
+ * @param {object} sceneDescription 3D scene description.
45
+ * @returns {{ tracks: any[], arcs: any[], copperTexts: any[], vias: any[] }}
46
+ */
47
+ static resolveCoveredByMask(sceneDescription) {
48
+ const detail = sceneDescription?.detail || {}
49
+
50
+ if (
51
+ !PcbScene3dCopperDetailFilter.#usesRealisticMasking(
52
+ sceneDescription
53
+ )
54
+ ) {
55
+ return { tracks: [], arcs: [], copperTexts: [], vias: [] }
56
+ }
57
+
58
+ const defaultCovered =
59
+ PcbScene3dCopperDetailFilter.#usesDefaultCoveredCopper(
60
+ sceneDescription
61
+ )
62
+
63
+ return {
64
+ tracks: PcbScene3dCopperDetailFilter.#filterMaskCoveredPrimitives(
65
+ detail.tracks,
66
+ defaultCovered
67
+ ),
68
+ arcs: PcbScene3dCopperDetailFilter.#filterMaskCoveredPrimitives(
69
+ detail.arcs,
70
+ defaultCovered
71
+ ),
72
+ copperTexts: [],
73
+ vias: []
74
+ }
75
+ }
76
+
42
77
  /**
43
78
  * Checks whether standalone via annuli should be rendered.
44
79
  * @param {object} sceneDescription 3D scene description.
@@ -101,6 +136,24 @@ export class PcbScene3dCopperDetailFilter {
101
136
  )
102
137
  }
103
138
 
139
+ /**
140
+ * Checks whether tracks are considered mask-covered without per-primitive
141
+ * mask metadata.
142
+ * @param {object} sceneDescription 3D scene description.
143
+ * @returns {boolean}
144
+ */
145
+ static #usesDefaultCoveredCopper(sceneDescription) {
146
+ const sourceFormat = String(sceneDescription?.sourceFormat || '')
147
+ .trim()
148
+ .toLowerCase()
149
+
150
+ return (
151
+ sourceFormat === 'altium' ||
152
+ sourceFormat === 'kicad' ||
153
+ sceneDescription?.coordinateSystem === 'kicad-3d-y-up'
154
+ )
155
+ }
156
+
104
157
  /**
105
158
  * Checks whether parsed copper detail carries explicit solder-mask data.
106
159
  * @param {object | undefined} detail Scene detail.
@@ -132,6 +185,25 @@ export class PcbScene3dCopperDetailFilter {
132
185
  )
133
186
  }
134
187
 
188
+ /**
189
+ * Keeps trace-like copper primitives that are covered by solder mask.
190
+ * @param {any[] | undefined} primitives Copper primitive list.
191
+ * @param {boolean} defaultCovered Whether missing metadata means covered.
192
+ * @returns {any[]}
193
+ */
194
+ static #filterMaskCoveredPrimitives(primitives, defaultCovered) {
195
+ return (primitives || []).filter((primitive) => {
196
+ if (PcbScene3dCopperDetailFilter.#hasMaskOpening(primitive)) {
197
+ return false
198
+ }
199
+
200
+ return (
201
+ defaultCovered ||
202
+ PcbScene3dCopperDetailFilter.#hasMaskMetadata(primitive)
203
+ )
204
+ })
205
+ }
206
+
135
207
  /**
136
208
  * Keeps vias only when they are explicitly not tented.
137
209
  * @param {any[] | undefined} vias Via list.
@@ -243,7 +315,7 @@ export class PcbScene3dCopperDetailFilter {
243
315
  * @returns {boolean}
244
316
  */
245
317
  static #hasMaskOpening(primitive, maskMatcher = null) {
246
- if (primitive?.hasSolderMask === true) {
318
+ if (primitive?.hasSolderMask === false) {
247
319
  return true
248
320
  }
249
321
 
@@ -0,0 +1,482 @@
1
+ import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+ import { PcbScene3dCopperDetailFilter } from './PcbScene3dCopperDetailFilter.mjs'
3
+ import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
4
+ import { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
5
+ import { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
6
+
7
+ /**
8
+ * Builds deferred copper-detail groups for the 3D runtime.
9
+ */
10
+ export class PcbScene3dCopperDetailGroupBuilder {
11
+ static #CIRCLE_SEGMENTS = 64
12
+ static #ROUNDED_CORNER_SEGMENTS = 12
13
+
14
+ /**
15
+ * Builds visible exposed and mask-covered copper detail.
16
+ * @param {any} THREE Three.js namespace.
17
+ * @param {object} sceneDescription Scene description.
18
+ * @param {number} topZ Top copper center Z.
19
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint
20
+ * @returns {any}
21
+ */
22
+ static build(THREE, sceneDescription, topZ, normalizePoint) {
23
+ const group = new THREE.Group()
24
+ const coveredGroup =
25
+ PcbScene3dCopperDetailGroupBuilder.#buildCoveredGroup(
26
+ THREE,
27
+ sceneDescription,
28
+ topZ,
29
+ normalizePoint
30
+ )
31
+ const exposedGroup =
32
+ PcbScene3dCopperDetailGroupBuilder.#buildExposedGroup(
33
+ THREE,
34
+ sceneDescription,
35
+ topZ,
36
+ normalizePoint
37
+ )
38
+ const viaGroup = PcbScene3dCopperDetailGroupBuilder.#buildViaGroup(
39
+ THREE,
40
+ sceneDescription,
41
+ normalizePoint
42
+ )
43
+
44
+ ;[coveredGroup, exposedGroup, viaGroup]
45
+ .filter((child) => child.children.length)
46
+ .forEach((child) => group.add(child))
47
+
48
+ return group
49
+ }
50
+
51
+ /**
52
+ * Builds traces covered by solder mask.
53
+ * @param {any} THREE Three.js namespace.
54
+ * @param {object} sceneDescription Scene description.
55
+ * @param {number} topZ Top copper center Z.
56
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint
57
+ * @returns {any}
58
+ */
59
+ static #buildCoveredGroup(THREE, sceneDescription, topZ, normalizePoint) {
60
+ return PcbScene3dCopperFactory.buildMaskCoveredGroup(
61
+ THREE,
62
+ PcbScene3dCopperDetailFilter.resolveCoveredByMask(sceneDescription),
63
+ topZ,
64
+ -topZ,
65
+ normalizePoint,
66
+ {
67
+ solderMaskColor:
68
+ PcbScene3dBoardMaterialPalette.resolveSurfaceColor(
69
+ sceneDescription?.board,
70
+ {
71
+ hasBoardAssemblyModel: Boolean(
72
+ sceneDescription?.boardAssemblyModel
73
+ )
74
+ }
75
+ ),
76
+ occlusionCutouts:
77
+ PcbScene3dCopperDetailGroupBuilder.#resolveCoveredCopperOcclusions(
78
+ sceneDescription?.detail
79
+ )
80
+ }
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Builds exposed copper detail.
86
+ * @param {any} THREE Three.js namespace.
87
+ * @param {object} sceneDescription Scene description.
88
+ * @param {number} topZ Top copper center Z.
89
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint
90
+ * @returns {any}
91
+ */
92
+ static #buildExposedGroup(THREE, sceneDescription, topZ, normalizePoint) {
93
+ return PcbScene3dCopperFactory.buildGroup(
94
+ THREE,
95
+ PcbScene3dCopperDetailFilter.resolve(sceneDescription),
96
+ topZ,
97
+ -topZ,
98
+ normalizePoint,
99
+ { coordinateSystem: sceneDescription?.coordinateSystem }
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Builds exposed via and through-hole barrel detail.
105
+ * @param {any} THREE Three.js namespace.
106
+ * @param {object} sceneDescription Scene description.
107
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint
108
+ * @returns {any}
109
+ */
110
+ static #buildViaGroup(THREE, sceneDescription, normalizePoint) {
111
+ if (
112
+ !PcbScene3dCopperDetailFilter.shouldRenderStandaloneVias(
113
+ sceneDescription
114
+ )
115
+ ) {
116
+ return new THREE.Group()
117
+ }
118
+
119
+ return PcbScene3dViaFactory.buildGroup(
120
+ THREE,
121
+ PcbScene3dCopperDetailFilter.resolveStandaloneVias(
122
+ sceneDescription
123
+ ),
124
+ sceneDescription?.board?.thicknessMil,
125
+ normalizePoint
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Resolves opaque silkscreen fill polygons that should hide covered copper.
131
+ * @param {{ top?: object, bottom?: object } | undefined} silkscreen Scene silkscreen detail.
132
+ * @returns {{ top: { x: number, y: number }[][], bottom: { x: number, y: number }[][] }}
133
+ */
134
+ static #resolveSilkscreenFillOcclusions(silkscreen) {
135
+ return {
136
+ top: PcbScene3dCopperDetailGroupBuilder.#resolveSideFillOcclusions(
137
+ silkscreen?.top
138
+ ),
139
+ bottom: PcbScene3dCopperDetailGroupBuilder.#resolveSideFillOcclusions(
140
+ silkscreen?.bottom
141
+ )
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolves fill contours on one silkscreen side.
147
+ * @param {{ fills?: any[] } | undefined} side Side-specific silkscreen detail.
148
+ * @returns {{ x: number, y: number }[][]}
149
+ */
150
+ static #resolveSideFillOcclusions(side) {
151
+ return (Array.isArray(side?.fills) ? side.fills : [])
152
+ .map((fill) =>
153
+ Array.isArray(fill?.points) && fill.points.length >= 3
154
+ ? fill.points
155
+ : PcbScene3dCopperDetailGroupBuilder.#resolveBoxFillPoints(
156
+ fill
157
+ )
158
+ )
159
+ .map((points) =>
160
+ points
161
+ .map((point) => ({
162
+ x: Number(point?.x),
163
+ y: Number(point?.y)
164
+ }))
165
+ .filter(
166
+ (point) =>
167
+ Number.isFinite(point.x) && Number.isFinite(point.y)
168
+ )
169
+ )
170
+ .filter((points) => points.length >= 3)
171
+ }
172
+
173
+ /**
174
+ * Resolves a rectangular fill as a polygon contour.
175
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number } | undefined} fill Fill record.
176
+ * @returns {{ x: number, y: number }[]}
177
+ */
178
+ static #resolveBoxFillPoints(fill) {
179
+ const x1 = Number(fill?.x1)
180
+ const y1 = Number(fill?.y1)
181
+ const x2 = Number(fill?.x2)
182
+ const y2 = Number(fill?.y2)
183
+
184
+ if (
185
+ !Number.isFinite(x1) ||
186
+ !Number.isFinite(y1) ||
187
+ !Number.isFinite(x2) ||
188
+ !Number.isFinite(y2)
189
+ ) {
190
+ return []
191
+ }
192
+
193
+ return [
194
+ { x: x1, y: y1 },
195
+ { x: x2, y: y1 },
196
+ { x: x2, y: y2 },
197
+ { x: x1, y: y2 }
198
+ ]
199
+ }
200
+
201
+ /**
202
+ * Resolves opaque geometry that should hide mask-covered copper.
203
+ * @param {object | undefined} detail Scene detail.
204
+ * @returns {{ top: { x: number, y: number }[][], bottom: { x: number, y: number }[][] }}
205
+ */
206
+ static #resolveCoveredCopperOcclusions(detail) {
207
+ const silkscreenOcclusions =
208
+ PcbScene3dCopperDetailGroupBuilder.#resolveSilkscreenFillOcclusions(
209
+ detail?.silkscreen
210
+ )
211
+
212
+ return {
213
+ top: silkscreenOcclusions.top.concat(
214
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfaceOcclusions(
215
+ detail?.pads,
216
+ 'top'
217
+ )
218
+ ),
219
+ bottom: silkscreenOcclusions.bottom.concat(
220
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfaceOcclusions(
221
+ detail?.pads,
222
+ 'bottom'
223
+ )
224
+ )
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Resolves exposed pad faces on one side as occlusion polygons.
230
+ * @param {object[] | undefined} pads Pad detail rows.
231
+ * @param {'top' | 'bottom'} side Board side.
232
+ * @returns {{ x: number, y: number }[][]}
233
+ */
234
+ static #resolvePadSurfaceOcclusions(pads, side) {
235
+ return (Array.isArray(pads) ? pads : [])
236
+ .filter((pad) =>
237
+ PcbScene3dCopperDetailGroupBuilder.#hasVisiblePadSurface(
238
+ pad,
239
+ side
240
+ )
241
+ )
242
+ .map((pad) =>
243
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfacePolygon(
244
+ pad,
245
+ side
246
+ )
247
+ )
248
+ .filter((points) => points.length >= 3)
249
+ }
250
+
251
+ /**
252
+ * Checks whether one pad face has exposed copper on a side.
253
+ * @param {object} pad Pad detail row.
254
+ * @param {'top' | 'bottom'} side Board side.
255
+ * @returns {boolean}
256
+ */
257
+ static #hasVisiblePadSurface(pad, side) {
258
+ if (
259
+ PcbScene3dCopperDetailGroupBuilder.#resolveSolderMaskOpening(
260
+ pad,
261
+ side
262
+ ) === false
263
+ ) {
264
+ return false
265
+ }
266
+
267
+ if (PcbScene3dCopperDetailGroupBuilder.#hasSideSize(pad, side)) {
268
+ return true
269
+ }
270
+
271
+ const oppositeSide = side === 'bottom' ? 'top' : 'bottom'
272
+ if (
273
+ PcbScene3dCopperDetailGroupBuilder.#hasSideSize(pad, oppositeSide)
274
+ ) {
275
+ return false
276
+ }
277
+
278
+ return (
279
+ Number(pad?.sizeMidX || 0) > 0 ||
280
+ Number(pad?.sizeMidY || 0) > 0 ||
281
+ Number(pad?.holeDiameter || 0) > 0
282
+ )
283
+ }
284
+
285
+ /**
286
+ * Resolves one side-specific solder mask opening flag.
287
+ * @param {object} pad Pad detail row.
288
+ * @param {'top' | 'bottom'} side Board side.
289
+ * @returns {boolean | null}
290
+ */
291
+ static #resolveSolderMaskOpening(pad, side) {
292
+ const fieldName =
293
+ side === 'bottom'
294
+ ? 'hasBottomSolderMaskOpening'
295
+ : 'hasTopSolderMaskOpening'
296
+
297
+ return typeof pad?.[fieldName] === 'boolean' ? pad[fieldName] : null
298
+ }
299
+
300
+ /**
301
+ * Checks whether one pad has an explicit copper size for a side.
302
+ * @param {object} pad Pad detail row.
303
+ * @param {'top' | 'bottom'} side Board side.
304
+ * @returns {boolean}
305
+ */
306
+ static #hasSideSize(pad, side) {
307
+ return side === 'bottom'
308
+ ? Number(pad?.sizeBottomX || 0) > 0 ||
309
+ Number(pad?.sizeBottomY || 0) > 0
310
+ : Number(pad?.sizeTopX || 0) > 0 || Number(pad?.sizeTopY || 0) > 0
311
+ }
312
+
313
+ /**
314
+ * Resolves one pad surface as a board-coordinate polygon.
315
+ * @param {object} pad Pad detail row.
316
+ * @param {'top' | 'bottom'} side Board side.
317
+ * @returns {{ x: number, y: number }[]}
318
+ */
319
+ static #resolvePadSurfacePolygon(pad, side) {
320
+ const spec = PcbScene3dPadFactory.resolvePadSurfaceSpec(pad, side)
321
+ const center = {
322
+ x: Number(pad?.x || 0) + spec.offsetX,
323
+ y: Number(pad?.y || 0) + spec.offsetY
324
+ }
325
+
326
+ return PcbScene3dCopperDetailGroupBuilder.#transformPoints(
327
+ PcbScene3dCopperDetailGroupBuilder.#buildPadLocalPoints(spec),
328
+ center,
329
+ Number(pad?.rotation || 0)
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Builds local points for one pad face.
335
+ * @param {{ width: number, height: number, kind: string, cornerRadius: number }} spec Pad surface spec.
336
+ * @returns {{ x: number, y: number }[]}
337
+ */
338
+ static #buildPadLocalPoints(spec) {
339
+ if (spec.kind === 'circle') {
340
+ return PcbScene3dCopperDetailGroupBuilder.#buildCirclePoints(
341
+ Math.max(spec.width, spec.height) / 2
342
+ )
343
+ }
344
+
345
+ if (spec.kind === 'rounded-rect' && Number(spec.cornerRadius) > 0) {
346
+ return PcbScene3dCopperDetailGroupBuilder.#buildRoundedRectPoints(
347
+ spec.width,
348
+ spec.height,
349
+ spec.cornerRadius
350
+ )
351
+ }
352
+
353
+ return PcbScene3dCopperDetailGroupBuilder.#buildRectPoints(
354
+ spec.width,
355
+ spec.height
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Builds a circular polygon centered at the origin.
361
+ * @param {number} radius Circle radius.
362
+ * @returns {{ x: number, y: number }[]}
363
+ */
364
+ static #buildCirclePoints(radius) {
365
+ return Array.from(
366
+ { length: PcbScene3dCopperDetailGroupBuilder.#CIRCLE_SEGMENTS },
367
+ (_unused, index) => {
368
+ const angle =
369
+ (Math.PI * 2 * index) /
370
+ PcbScene3dCopperDetailGroupBuilder.#CIRCLE_SEGMENTS
371
+
372
+ return {
373
+ x: Math.cos(angle) * radius,
374
+ y: Math.sin(angle) * radius
375
+ }
376
+ }
377
+ )
378
+ }
379
+
380
+ /**
381
+ * Builds a rectangle polygon centered at the origin.
382
+ * @param {number} width Rectangle width.
383
+ * @param {number} height Rectangle height.
384
+ * @returns {{ x: number, y: number }[]}
385
+ */
386
+ static #buildRectPoints(width, height) {
387
+ const halfWidth = Number(width || 0) / 2
388
+ const halfHeight = Number(height || 0) / 2
389
+
390
+ return [
391
+ { x: -halfWidth, y: -halfHeight },
392
+ { x: halfWidth, y: -halfHeight },
393
+ { x: halfWidth, y: halfHeight },
394
+ { x: -halfWidth, y: halfHeight }
395
+ ]
396
+ }
397
+
398
+ /**
399
+ * Builds a rounded rectangle polygon centered at the origin.
400
+ * @param {number} width Rectangle width.
401
+ * @param {number} height Rectangle height.
402
+ * @param {number} radius Corner radius.
403
+ * @returns {{ x: number, y: number }[]}
404
+ */
405
+ static #buildRoundedRectPoints(width, height, radius) {
406
+ const halfWidth = Number(width || 0) / 2
407
+ const halfHeight = Number(height || 0) / 2
408
+ const cornerRadius = Math.min(
409
+ Math.max(Number(radius || 0), 0),
410
+ halfWidth,
411
+ halfHeight
412
+ )
413
+ const corners = [
414
+ { x: halfWidth - cornerRadius, y: halfHeight - cornerRadius },
415
+ { x: -halfWidth + cornerRadius, y: halfHeight - cornerRadius },
416
+ { x: -halfWidth + cornerRadius, y: -halfHeight + cornerRadius },
417
+ { x: halfWidth - cornerRadius, y: -halfHeight + cornerRadius }
418
+ ]
419
+ const angleRanges = [
420
+ [0, Math.PI / 2],
421
+ [Math.PI / 2, Math.PI],
422
+ [Math.PI, (Math.PI * 3) / 2],
423
+ [(Math.PI * 3) / 2, Math.PI * 2]
424
+ ]
425
+
426
+ return corners.flatMap((corner, cornerIndex) =>
427
+ PcbScene3dCopperDetailGroupBuilder.#buildCornerPoints(
428
+ corner,
429
+ cornerRadius,
430
+ angleRanges[cornerIndex]
431
+ )
432
+ )
433
+ }
434
+
435
+ /**
436
+ * Builds sampled points for one rounded corner.
437
+ * @param {{ x: number, y: number }} center Corner arc center.
438
+ * @param {number} radius Corner radius.
439
+ * @param {number[]} angleRange Start and end angle.
440
+ * @returns {{ x: number, y: number }[]}
441
+ */
442
+ static #buildCornerPoints(center, radius, angleRange) {
443
+ const [startAngle, endAngle] = angleRange
444
+
445
+ return Array.from(
446
+ {
447
+ length:
448
+ PcbScene3dCopperDetailGroupBuilder
449
+ .#ROUNDED_CORNER_SEGMENTS + 1
450
+ },
451
+ (_unused, index) => {
452
+ const ratio =
453
+ index /
454
+ PcbScene3dCopperDetailGroupBuilder.#ROUNDED_CORNER_SEGMENTS
455
+ const angle = startAngle + (endAngle - startAngle) * ratio
456
+
457
+ return {
458
+ x: center.x + Math.cos(angle) * radius,
459
+ y: center.y + Math.sin(angle) * radius
460
+ }
461
+ }
462
+ )
463
+ }
464
+
465
+ /**
466
+ * Rotates and translates local points into board coordinates.
467
+ * @param {{ x: number, y: number }[]} points Local points.
468
+ * @param {{ x: number, y: number }} center Board center.
469
+ * @param {number} rotationDeg Rotation in degrees.
470
+ * @returns {{ x: number, y: number }[]}
471
+ */
472
+ static #transformPoints(points, center, rotationDeg) {
473
+ const angle = (Number(rotationDeg || 0) * Math.PI) / 180
474
+ const cos = Math.cos(angle)
475
+ const sin = Math.sin(angle)
476
+
477
+ return points.map((point) => ({
478
+ x: center.x + point.x * cos - point.y * sin,
479
+ y: center.y + point.x * sin + point.y * cos
480
+ }))
481
+ }
482
+ }