pcb-scene3d-viewer 1.2.2 → 1.3.0

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 (32) hide show
  1. package/README.md +7 -0
  2. package/docs/api.md +18 -0
  3. package/docs/circuitjson.md +6 -4
  4. package/docs/release-notes-v1.3.0.md +38 -0
  5. package/package.json +3 -2
  6. package/src/CircuitJsonCadModelAssetResolver.mjs +24 -10
  7. package/src/PcbAssemblyGltfModelMeshParser.mjs +3 -1
  8. package/src/PcbAssemblyModelMeshLoader.mjs +1 -1
  9. package/src/PcbAssemblyTextModelMeshParser.mjs +3 -1
  10. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -11
  11. package/src/PcbScene3dBoardMaterialPalette.mjs +12 -0
  12. package/src/PcbScene3dCircuitJsonAdapter.mjs +33 -85
  13. package/src/PcbScene3dCircuitJsonCopperTextBuilder.mjs +385 -0
  14. package/src/PcbScene3dCircuitJsonDocumentationArtworkBuilder.mjs +123 -22
  15. package/src/PcbScene3dCircuitJsonGeometry.mjs +12 -0
  16. package/src/PcbScene3dCircuitJsonPadCorner.mjs +72 -0
  17. package/src/PcbScene3dCircuitJsonSilkscreenBuilder.mjs +140 -25
  18. package/src/PcbScene3dCircuitJsonSilkscreenDetailBuilder.mjs +21 -0
  19. package/src/PcbScene3dCircuitJsonSourceLayer.mjs +124 -0
  20. package/src/PcbScene3dCircuitJsonTraceRouteBuilder.mjs +6 -7
  21. package/src/PcbScene3dCopperDetailFilter.mjs +5 -1
  22. package/src/PcbScene3dCopperFactory.mjs +23 -4
  23. package/src/PcbScene3dCopperTextFactory.mjs +105 -20
  24. package/src/PcbScene3dExternalModels.mjs +26 -0
  25. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +16 -1
  26. package/src/PcbScene3dModelContent.mjs +32 -1
  27. package/src/PcbScene3dRuntimeBoardMeshes.mjs +2 -4
  28. package/src/PcbScene3dSilkscreenCopperCutoutBuilder.mjs +588 -0
  29. package/src/PcbScene3dStepLoader.mjs +2 -1
  30. package/src/PcbScene3dStrokeCutoutBuilder.mjs +98 -0
  31. package/src/PcbScene3dViaFactory.mjs +51 -5
  32. package/src/PcbScene3dViaLayerSpan.mjs +126 -0
@@ -0,0 +1,588 @@
1
+ import earcut from 'earcut'
2
+ import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
3
+ import { PcbScene3dCircuitJsonLayer } from './PcbScene3dCircuitJsonLayer.mjs'
4
+ import { PcbScene3dCopperTextFactory } from './PcbScene3dCopperTextFactory.mjs'
5
+ import { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
6
+ import { PcbScene3dCopperDrillCutoutBuilder } from './PcbScene3dCopperDrillCutoutBuilder.mjs'
7
+ import { PcbScene3dStrokeCutoutBuilder } from './PcbScene3dStrokeCutoutBuilder.mjs'
8
+ import { PcbScene3dViaLayerSpan } from './PcbScene3dViaLayerSpan.mjs'
9
+
10
+ /**
11
+ * Builds board-space surface keepouts used to clip silkscreen primitives.
12
+ */
13
+ export class PcbScene3dSilkscreenCopperCutoutBuilder {
14
+ static #CIRCLE_SEGMENTS = 48
15
+ static #ROUNDED_CORNER_SEGMENTS = 12
16
+
17
+ /**
18
+ * Adds generated copper and drill keepouts to renderable silkscreen faces.
19
+ * @param {{ top?: object, bottom?: object } | undefined} silkscreen Silkscreen detail.
20
+ * @param {{ pads?: object[], vias?: object[], tracks?: object[], fills?: object[], polygons?: object[], copperTexts?: object[] }} detail Scene detail.
21
+ * @returns {{ top: object, bottom: object }}
22
+ */
23
+ static apply(silkscreen, detail) {
24
+ const topHasSilkscreen =
25
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasRenderableSilkscreen(
26
+ silkscreen?.top
27
+ )
28
+ const bottomHasSilkscreen =
29
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasRenderableSilkscreen(
30
+ silkscreen?.bottom
31
+ )
32
+ const copperCutouts = PcbScene3dSilkscreenCopperCutoutBuilder.build(
33
+ detail,
34
+ {
35
+ top: topHasSilkscreen,
36
+ bottom: bottomHasSilkscreen
37
+ }
38
+ )
39
+ const drillCutouts = {
40
+ top: topHasSilkscreen
41
+ ? PcbScene3dSilkscreenCopperCutoutBuilder.#buildDrillCutouts(
42
+ detail,
43
+ 'top'
44
+ )
45
+ : [],
46
+ bottom: bottomHasSilkscreen
47
+ ? PcbScene3dSilkscreenCopperCutoutBuilder.#buildDrillCutouts(
48
+ detail,
49
+ 'bottom'
50
+ )
51
+ : []
52
+ }
53
+
54
+ return {
55
+ ...silkscreen,
56
+ top: PcbScene3dSilkscreenCopperCutoutBuilder.#composeSide(
57
+ silkscreen?.top,
58
+ copperCutouts.top,
59
+ drillCutouts.top,
60
+ topHasSilkscreen
61
+ ),
62
+ bottom: PcbScene3dSilkscreenCopperCutoutBuilder.#composeSide(
63
+ silkscreen?.bottom,
64
+ copperCutouts.bottom,
65
+ drillCutouts.bottom,
66
+ bottomHasSilkscreen
67
+ )
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Builds side-specific copper keepouts from normalized scene detail.
73
+ * @param {{ pads?: object[], vias?: object[], tracks?: object[], fills?: object[], polygons?: object[], copperTexts?: object[] }} detail Scene detail.
74
+ * @param {{ top?: boolean, bottom?: boolean }} [sides] Faces that contain renderable silkscreen.
75
+ * @returns {{ top: { x: number, y: number }[][], bottom: { x: number, y: number }[][] }}
76
+ */
77
+ static build(detail, sides = {}) {
78
+ const pads = Array.isArray(detail?.pads) ? detail.pads : []
79
+ const vias = Array.isArray(detail?.vias) ? detail.vias : []
80
+ const tracks = Array.isArray(detail?.tracks) ? detail.tracks : []
81
+ const fills = [
82
+ ...(Array.isArray(detail?.fills) ? detail.fills : []),
83
+ ...(Array.isArray(detail?.polygons) ? detail.polygons : [])
84
+ ]
85
+ const copperTexts = Array.isArray(detail?.copperTexts)
86
+ ? detail.copperTexts
87
+ : []
88
+ const surfaceDetail = { pads, vias, tracks, fills, copperTexts }
89
+
90
+ return {
91
+ top:
92
+ sides.top === false
93
+ ? []
94
+ : PcbScene3dSilkscreenCopperCutoutBuilder.#buildSide(
95
+ surfaceDetail,
96
+ 'top'
97
+ ),
98
+ bottom:
99
+ sides.bottom === false
100
+ ? []
101
+ : PcbScene3dSilkscreenCopperCutoutBuilder.#buildSide(
102
+ surfaceDetail,
103
+ 'bottom'
104
+ )
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns true when one face contains silkscreen geometry to clip.
110
+ * @param {object | undefined} silkscreen Face-specific silkscreen detail.
111
+ * @returns {boolean}
112
+ */
113
+ static #hasRenderableSilkscreen(silkscreen) {
114
+ return ['fills', 'tracks', 'arcs', 'texts'].some(
115
+ (name) =>
116
+ Array.isArray(silkscreen?.[name]) && silkscreen[name].length > 0
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Merges generated keepouts with authored face detail.
122
+ * @param {object | undefined} silkscreen Face-specific silkscreen detail.
123
+ * @param {{ x: number, y: number }[][]} copperCutouts Generated copper keepouts.
124
+ * @param {{ x: number, y: number }[][]} drillCutouts Generated drill keepouts.
125
+ * @param {boolean} hasSilkscreen Whether the face contains geometry to clip.
126
+ * @returns {object}
127
+ */
128
+ static #composeSide(
129
+ silkscreen,
130
+ copperCutouts,
131
+ drillCutouts,
132
+ hasSilkscreen
133
+ ) {
134
+ return {
135
+ ...silkscreen,
136
+ copperCutouts: [
137
+ ...(silkscreen?.copperCutouts || []),
138
+ ...(hasSilkscreen ? copperCutouts : [])
139
+ ],
140
+ drillCutouts: [
141
+ ...(silkscreen?.drillCutouts || []),
142
+ ...(hasSilkscreen ? drillCutouts : [])
143
+ ]
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Builds copper keepouts for one board face.
149
+ * @param {{ pads: object[], vias: object[], tracks: object[], fills: object[], copperTexts: object[] }} detail Normalized surface detail.
150
+ * @param {'top' | 'bottom'} side Board face.
151
+ * @returns {{ x: number, y: number }[][]}
152
+ */
153
+ static #buildSide(detail, side) {
154
+ return [
155
+ ...detail.pads
156
+ .filter((pad) =>
157
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasVisiblePadSurface(
158
+ pad,
159
+ side
160
+ )
161
+ )
162
+ .map((pad) =>
163
+ PcbScene3dSilkscreenCopperCutoutBuilder.#buildPadCutout(
164
+ pad,
165
+ side
166
+ )
167
+ ),
168
+ ...detail.vias
169
+ .filter((via) =>
170
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasVisibleViaSurface(
171
+ via,
172
+ side
173
+ )
174
+ )
175
+ .map((via) =>
176
+ PcbScene3dSilkscreenCopperCutoutBuilder.#buildViaCutout(via)
177
+ ),
178
+ ...detail.tracks
179
+ .filter((track) =>
180
+ PcbScene3dSilkscreenCopperCutoutBuilder.#isOpenOnSide(
181
+ track,
182
+ side
183
+ )
184
+ )
185
+ .map((track) =>
186
+ PcbScene3dSilkscreenCopperCutoutBuilder.#buildTrackCutout(
187
+ track
188
+ )
189
+ ),
190
+ ...detail.fills
191
+ .filter((fill) =>
192
+ PcbScene3dSilkscreenCopperCutoutBuilder.#isOpenOnSide(
193
+ fill,
194
+ side
195
+ )
196
+ )
197
+ .flatMap((fill) =>
198
+ PcbScene3dSilkscreenCopperCutoutBuilder.#buildFillCutouts(
199
+ fill
200
+ )
201
+ ),
202
+ ...detail.copperTexts
203
+ .filter((text) =>
204
+ PcbScene3dSilkscreenCopperCutoutBuilder.#isOpenOnSide(
205
+ text,
206
+ side
207
+ )
208
+ )
209
+ .flatMap((text) =>
210
+ PcbScene3dCopperTextFactory.strokeCutouts(text)
211
+ )
212
+ ].filter((points) => points.length >= 3)
213
+ }
214
+
215
+ /**
216
+ * Builds one round-capped track keepout.
217
+ * @param {object} track Normalized track.
218
+ * @returns {{ x: number, y: number }[]}
219
+ */
220
+ static #buildTrackCutout(track) {
221
+ return PcbScene3dStrokeCutoutBuilder.build(
222
+ { x: Number(track?.x1), y: Number(track?.y1) },
223
+ { x: Number(track?.x2), y: Number(track?.y2) },
224
+ Number(track?.width || 0),
225
+ { minWidth: 1 }
226
+ )
227
+ }
228
+
229
+ /**
230
+ * Builds exact filled-area keepouts, triangulating only shapes with holes.
231
+ * @param {object} fill Normalized copper fill or polygon.
232
+ * @returns {{ x: number, y: number }[][]}
233
+ */
234
+ static #buildFillCutouts(fill) {
235
+ return PcbAssemblyFillGeometryResolver.resolveAll(fill).flatMap(
236
+ (loops) =>
237
+ loops.holes.length
238
+ ? PcbScene3dSilkscreenCopperCutoutBuilder.#triangulateLoopSet(
239
+ loops
240
+ )
241
+ : [
242
+ loops.outer.map((point) => ({
243
+ x: Number(point?.[0]),
244
+ y: Number(point?.[1])
245
+ }))
246
+ ]
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Triangulates a filled loop set so authored voids remain printable.
252
+ * @param {{ outer: number[][], holes: number[][][] }} loops Fill loops.
253
+ * @returns {{ x: number, y: number }[][]}
254
+ */
255
+ static #triangulateLoopSet(loops) {
256
+ const rings = [loops.outer, ...(loops.holes || [])]
257
+ const points = rings.flat()
258
+ const flatPoints = points.flatMap((point) => [
259
+ Number(point?.[0]),
260
+ Number(point?.[1])
261
+ ])
262
+ const holeIndexes = []
263
+ let vertexIndex = loops.outer.length
264
+ for (const hole of loops.holes || []) {
265
+ holeIndexes.push(vertexIndex)
266
+ vertexIndex += hole.length
267
+ }
268
+ const indexes = earcut(flatPoints, holeIndexes, 2)
269
+ const triangles = []
270
+ for (let index = 0; index < indexes.length; index += 3) {
271
+ triangles.push(
272
+ indexes.slice(index, index + 3).map((pointIndex) => ({
273
+ x: Number(points[pointIndex]?.[0]),
274
+ y: Number(points[pointIndex]?.[1])
275
+ }))
276
+ )
277
+ }
278
+ return triangles
279
+ }
280
+
281
+ /**
282
+ * Returns true when copper is exposed on the requested board face.
283
+ * @param {object} primitive Normalized surface primitive.
284
+ * @param {'top' | 'bottom'} side Board face.
285
+ * @returns {boolean}
286
+ */
287
+ static #isOpenOnSide(primitive, side) {
288
+ const isOpen =
289
+ primitive?.solderMaskOpening === true ||
290
+ primitive?.hasSolderMask === false
291
+ return (
292
+ isOpen &&
293
+ PcbScene3dSilkscreenCopperCutoutBuilder.#primitiveSide(
294
+ primitive
295
+ ) === side
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Resolves one normalized primitive side.
301
+ * @param {object} primitive Surface primitive.
302
+ * @returns {'top' | 'bottom'}
303
+ */
304
+ static #primitiveSide(primitive) {
305
+ const layerId = Number(primitive?.layerId)
306
+ if (layerId === 32) return 'bottom'
307
+ if (layerId === 1) return 'top'
308
+
309
+ return (
310
+ PcbScene3dCircuitJsonLayer.surfaceSide(
311
+ primitive?.side ?? primitive?.layer
312
+ ) ||
313
+ PcbScene3dCircuitJsonLayer.side(primitive?.side ?? primitive?.layer)
314
+ )
315
+ }
316
+
317
+ /**
318
+ * Builds physical drill cutouts for the vias that reach one board face.
319
+ * Through-hole pad drills continue to apply to both faces.
320
+ * @param {{ pads?: object[], vias?: object[] }} detail Scene detail.
321
+ * @param {'top' | 'bottom'} side Board face.
322
+ * @returns {{ x: number, y: number }[][]}
323
+ */
324
+ static #buildDrillCutouts(detail, side) {
325
+ return PcbScene3dCopperDrillCutoutBuilder.resolve({
326
+ ...detail,
327
+ vias: (Array.isArray(detail?.vias) ? detail.vias : []).filter(
328
+ (via) => PcbScene3dViaLayerSpan.reachesSide(via, side)
329
+ )
330
+ })
331
+ }
332
+
333
+ /**
334
+ * Returns true when a pad exposes copper on one board face.
335
+ * @param {object} pad Normalized pad.
336
+ * @param {'top' | 'bottom'} side Board face.
337
+ * @returns {boolean}
338
+ */
339
+ static #hasVisiblePadSurface(pad, side) {
340
+ const openingField =
341
+ side === 'bottom'
342
+ ? 'hasBottomSolderMaskOpening'
343
+ : 'hasTopSolderMaskOpening'
344
+ if (pad?.[openingField] === false) {
345
+ return false
346
+ }
347
+
348
+ if (
349
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasPadSideSize(pad, side)
350
+ ) {
351
+ return true
352
+ }
353
+
354
+ const alternateSide = side === 'bottom' ? 'top' : 'bottom'
355
+ const alternateSideHasSize =
356
+ PcbScene3dSilkscreenCopperCutoutBuilder.#hasPadSideSize(
357
+ pad,
358
+ alternateSide
359
+ )
360
+ const midHasSize =
361
+ Number(pad?.sizeMidX || 0) > 0 || Number(pad?.sizeMidY || 0) > 0
362
+
363
+ return (
364
+ !alternateSideHasSize &&
365
+ (midHasSize || Number(pad?.holeDiameter || 0) > 0)
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Returns true when a pad has an explicit copper size on one face.
371
+ * @param {object} pad Normalized pad.
372
+ * @param {'top' | 'bottom'} side Board face.
373
+ * @returns {boolean}
374
+ */
375
+ static #hasPadSideSize(pad, side) {
376
+ if (side === 'bottom') {
377
+ return (
378
+ Number(pad?.sizeBottomX || 0) > 0 ||
379
+ Number(pad?.sizeBottomY || 0) > 0
380
+ )
381
+ }
382
+
383
+ return Number(pad?.sizeTopX || 0) > 0 || Number(pad?.sizeTopY || 0) > 0
384
+ }
385
+
386
+ /**
387
+ * Returns true when a via exposes copper on one board face.
388
+ * @param {object} via Normalized via.
389
+ * @param {'top' | 'bottom'} side Board face.
390
+ * @returns {boolean}
391
+ */
392
+ static #hasVisibleViaSurface(via, side) {
393
+ if (!PcbScene3dViaLayerSpan.reachesSide(via, side)) {
394
+ return false
395
+ }
396
+
397
+ const openingField =
398
+ side === 'bottom'
399
+ ? 'hasBottomSolderMaskOpening'
400
+ : 'hasTopSolderMaskOpening'
401
+ if (typeof via?.[openingField] === 'boolean') {
402
+ return via[openingField] && Number(via?.diameter || 0) > 0
403
+ }
404
+
405
+ const tentingField =
406
+ side === 'bottom' ? 'isTentingBottom' : 'isTentingTop'
407
+ return via?.[tentingField] !== true && Number(via?.diameter || 0) > 0
408
+ }
409
+
410
+ /**
411
+ * Builds one transformed pad keepout in board coordinates.
412
+ * @param {object} pad Normalized pad.
413
+ * @param {'top' | 'bottom'} side Board face.
414
+ * @returns {{ x: number, y: number }[]}
415
+ */
416
+ static #buildPadCutout(pad, side) {
417
+ const spec = PcbScene3dPadFactory.resolvePadSurfaceSpec(pad, side)
418
+ const rotationRad = (Number(pad?.rotation || 0) * Math.PI) / 180
419
+ const cosine = Math.cos(rotationRad)
420
+ const sine = Math.sin(rotationRad)
421
+ const centerX = Number(pad?.x || 0)
422
+ const centerY = Number(pad?.y || 0)
423
+
424
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildPadLocalPoints(
425
+ spec
426
+ ).map((point) => {
427
+ const x = point.x + spec.offsetX
428
+ const y = point.y + spec.offsetY
429
+ return {
430
+ x: centerX + x * cosine - y * sine,
431
+ y: centerY + x * sine + y * cosine
432
+ }
433
+ })
434
+ }
435
+
436
+ /**
437
+ * Builds one circular via keepout in board coordinates.
438
+ * @param {object} via Normalized via.
439
+ * @returns {{ x: number, y: number }[]}
440
+ */
441
+ static #buildViaCutout(via) {
442
+ const radius = Math.max(Number(via?.diameter || 0) / 2, 0)
443
+ const centerX = Number(via?.x || 0)
444
+ const centerY = Number(via?.y || 0)
445
+
446
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildCirclePoints(
447
+ radius
448
+ ).map((point) => ({
449
+ x: centerX + point.x,
450
+ y: centerY + point.y
451
+ }))
452
+ }
453
+
454
+ /**
455
+ * Builds local outline points for one pad surface.
456
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', cornerRadius: number }} spec Pad surface.
457
+ * @returns {{ x: number, y: number }[]}
458
+ */
459
+ static #buildPadLocalPoints(spec) {
460
+ if (spec.kind === 'circle') {
461
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildCirclePoints(
462
+ Math.max(spec.width, spec.height) / 2
463
+ )
464
+ }
465
+
466
+ if (spec.kind === 'rounded-rect') {
467
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildRoundedRectPoints(
468
+ spec.width,
469
+ spec.height,
470
+ spec.cornerRadius
471
+ )
472
+ }
473
+
474
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildRectPoints(
475
+ spec.width,
476
+ spec.height
477
+ )
478
+ }
479
+
480
+ /**
481
+ * Builds local circular outline points.
482
+ * @param {number} radius Circle radius.
483
+ * @returns {{ x: number, y: number }[]}
484
+ */
485
+ static #buildCirclePoints(radius) {
486
+ return Array.from(
487
+ {
488
+ length: PcbScene3dSilkscreenCopperCutoutBuilder.#CIRCLE_SEGMENTS
489
+ },
490
+ (_, index) => {
491
+ const angle =
492
+ (Math.PI * 2 * index) /
493
+ PcbScene3dSilkscreenCopperCutoutBuilder.#CIRCLE_SEGMENTS
494
+ return {
495
+ x: Math.cos(angle) * radius,
496
+ y: Math.sin(angle) * radius
497
+ }
498
+ }
499
+ )
500
+ }
501
+
502
+ /**
503
+ * Builds local rectangular outline points.
504
+ * @param {number} width Rectangle width.
505
+ * @param {number} height Rectangle height.
506
+ * @returns {{ x: number, y: number }[]}
507
+ */
508
+ static #buildRectPoints(width, height) {
509
+ const halfWidth = Math.max(Number(width || 0) / 2, 0)
510
+ const halfHeight = Math.max(Number(height || 0) / 2, 0)
511
+ return [
512
+ { x: -halfWidth, y: -halfHeight },
513
+ { x: halfWidth, y: -halfHeight },
514
+ { x: halfWidth, y: halfHeight },
515
+ { x: -halfWidth, y: halfHeight }
516
+ ]
517
+ }
518
+
519
+ /**
520
+ * Builds local rounded-rectangle outline points.
521
+ * @param {number} width Rectangle width.
522
+ * @param {number} height Rectangle height.
523
+ * @param {number} cornerRadius Corner radius.
524
+ * @returns {{ x: number, y: number }[]}
525
+ */
526
+ static #buildRoundedRectPoints(width, height, cornerRadius) {
527
+ const halfWidth = Math.max(Number(width || 0) / 2, 0)
528
+ const halfHeight = Math.max(Number(height || 0) / 2, 0)
529
+ const radius = Math.min(
530
+ Math.max(Number(cornerRadius || 0), 0),
531
+ halfWidth,
532
+ halfHeight
533
+ )
534
+ if (radius <= 0.001) {
535
+ return PcbScene3dSilkscreenCopperCutoutBuilder.#buildRectPoints(
536
+ width,
537
+ height
538
+ )
539
+ }
540
+
541
+ const corners = [
542
+ {
543
+ x: halfWidth - radius,
544
+ y: halfHeight - radius,
545
+ start: 0
546
+ },
547
+ {
548
+ x: -halfWidth + radius,
549
+ y: halfHeight - radius,
550
+ start: Math.PI / 2
551
+ },
552
+ {
553
+ x: -halfWidth + radius,
554
+ y: -halfHeight + radius,
555
+ start: Math.PI
556
+ },
557
+ {
558
+ x: halfWidth - radius,
559
+ y: -halfHeight + radius,
560
+ start: (Math.PI * 3) / 2
561
+ }
562
+ ]
563
+ const points = []
564
+
565
+ corners.forEach((corner) => {
566
+ for (
567
+ let index = 0;
568
+ index <
569
+ PcbScene3dSilkscreenCopperCutoutBuilder
570
+ .#ROUNDED_CORNER_SEGMENTS;
571
+ index += 1
572
+ ) {
573
+ const angle =
574
+ corner.start +
575
+ (Math.PI / 2) *
576
+ (index /
577
+ PcbScene3dSilkscreenCopperCutoutBuilder
578
+ .#ROUNDED_CORNER_SEGMENTS)
579
+ points.push({
580
+ x: corner.x + Math.cos(angle) * radius,
581
+ y: corner.y + Math.sin(angle) * radius
582
+ })
583
+ }
584
+ })
585
+
586
+ return points
587
+ }
588
+ }
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
2
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
2
3
  import { PcbScene3dOcctImporterLoader } from './PcbScene3dOcctImporterLoader.mjs'
3
4
 
4
5
  /**
@@ -326,7 +327,7 @@ export class PcbScene3dStepLoader {
326
327
  }
327
328
  }
328
329
 
329
- throw new Error('STEP model content is not available.')
330
+ throw PcbScene3dModelContent.unavailableError('STEP')
330
331
  }
331
332
 
332
333
  /**
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Builds polygon cutouts matching round-capped scene stroke geometry.
3
+ */
4
+ export class PcbScene3dStrokeCutoutBuilder {
5
+ static #ROUND_CAP_SEGMENTS = 16
6
+
7
+ /**
8
+ * Builds one capsule-shaped cutout around a stroke segment.
9
+ * @param {{ x: number, y: number }} start Segment start.
10
+ * @param {{ x: number, y: number }} end Segment end.
11
+ * @param {number} width Stroke width.
12
+ * @param {{ minWidth?: number }} [options] Width constraints.
13
+ * @returns {{ x: number, y: number }[]}
14
+ */
15
+ static build(start, end, width, options = {}) {
16
+ const startPoint = PcbScene3dStrokeCutoutBuilder.#point(start)
17
+ const endPoint = PcbScene3dStrokeCutoutBuilder.#point(end)
18
+ if (!startPoint || !endPoint) return []
19
+
20
+ const radius =
21
+ Math.max(Number(width) || 1, Number(options?.minWidth) || 1) / 2
22
+ const dx = endPoint.x - startPoint.x
23
+ const dy = endPoint.y - startPoint.y
24
+ const length = Math.hypot(dx, dy)
25
+ if (length <= 0.001) {
26
+ return PcbScene3dStrokeCutoutBuilder.#circle(startPoint, radius)
27
+ }
28
+
29
+ const angle = Math.atan2(dy, dx)
30
+ return [
31
+ ...PcbScene3dStrokeCutoutBuilder.#semicircle(
32
+ startPoint,
33
+ radius,
34
+ angle + Math.PI / 2
35
+ ),
36
+ ...PcbScene3dStrokeCutoutBuilder.#semicircle(
37
+ endPoint,
38
+ radius,
39
+ angle - Math.PI / 2
40
+ )
41
+ ]
42
+ }
43
+
44
+ /**
45
+ * Builds one semicircular cap in contour order.
46
+ * @param {{ x: number, y: number }} center Cap center.
47
+ * @param {number} radius Cap radius.
48
+ * @param {number} startAngle Starting angle in radians.
49
+ * @returns {{ x: number, y: number }[]}
50
+ */
51
+ static #semicircle(center, radius, startAngle) {
52
+ return Array.from(
53
+ { length: PcbScene3dStrokeCutoutBuilder.#ROUND_CAP_SEGMENTS + 1 },
54
+ (_unused, index) => {
55
+ const angle =
56
+ startAngle +
57
+ (Math.PI * index) /
58
+ PcbScene3dStrokeCutoutBuilder.#ROUND_CAP_SEGMENTS
59
+ return {
60
+ x: center.x + Math.cos(angle) * radius,
61
+ y: center.y + Math.sin(angle) * radius
62
+ }
63
+ }
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Builds one circular cutout.
69
+ * @param {{ x: number, y: number }} center Circle center.
70
+ * @param {number} radius Circle radius.
71
+ * @returns {{ x: number, y: number }[]}
72
+ */
73
+ static #circle(center, radius) {
74
+ return Array.from(
75
+ { length: PcbScene3dStrokeCutoutBuilder.#ROUND_CAP_SEGMENTS },
76
+ (_unused, index) => {
77
+ const angle =
78
+ (Math.PI * 2 * index) /
79
+ PcbScene3dStrokeCutoutBuilder.#ROUND_CAP_SEGMENTS
80
+ return {
81
+ x: center.x + Math.cos(angle) * radius,
82
+ y: center.y + Math.sin(angle) * radius
83
+ }
84
+ }
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Resolves one finite point.
90
+ * @param {unknown} point Candidate point.
91
+ * @returns {{ x: number, y: number } | null}
92
+ */
93
+ static #point(point) {
94
+ const x = Number(point?.x)
95
+ const y = Number(point?.y)
96
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
97
+ }
98
+ }