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.
- package/README.md +7 -0
- package/docs/api.md +18 -0
- package/docs/circuitjson.md +6 -4
- package/docs/release-notes-v1.3.0.md +38 -0
- package/package.json +3 -2
- package/src/CircuitJsonCadModelAssetResolver.mjs +24 -10
- package/src/PcbAssemblyGltfModelMeshParser.mjs +3 -1
- package/src/PcbAssemblyModelMeshLoader.mjs +1 -1
- package/src/PcbAssemblyTextModelMeshParser.mjs +3 -1
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -11
- package/src/PcbScene3dBoardMaterialPalette.mjs +12 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +33 -85
- package/src/PcbScene3dCircuitJsonCopperTextBuilder.mjs +385 -0
- package/src/PcbScene3dCircuitJsonDocumentationArtworkBuilder.mjs +123 -22
- package/src/PcbScene3dCircuitJsonGeometry.mjs +12 -0
- package/src/PcbScene3dCircuitJsonPadCorner.mjs +72 -0
- package/src/PcbScene3dCircuitJsonSilkscreenBuilder.mjs +140 -25
- package/src/PcbScene3dCircuitJsonSilkscreenDetailBuilder.mjs +21 -0
- package/src/PcbScene3dCircuitJsonSourceLayer.mjs +124 -0
- package/src/PcbScene3dCircuitJsonTraceRouteBuilder.mjs +6 -7
- package/src/PcbScene3dCopperDetailFilter.mjs +5 -1
- package/src/PcbScene3dCopperFactory.mjs +23 -4
- package/src/PcbScene3dCopperTextFactory.mjs +105 -20
- package/src/PcbScene3dExternalModels.mjs +26 -0
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +16 -1
- package/src/PcbScene3dModelContent.mjs +32 -1
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +2 -4
- package/src/PcbScene3dSilkscreenCopperCutoutBuilder.mjs +588 -0
- package/src/PcbScene3dStepLoader.mjs +2 -1
- package/src/PcbScene3dStrokeCutoutBuilder.mjs +98 -0
- package/src/PcbScene3dViaFactory.mjs +51 -5
- 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
|
|
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
|
+
}
|