pcb-scene3d-viewer 1.0.1
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/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- package/src/styles/scene3d.css +295 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filters 3D copper detail to match realistic solder-mask visibility.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dCopperDetailFilter {
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the copper detail that should be visible in the 3D viewport.
|
|
7
|
+
* @param {object} sceneDescription 3D scene description.
|
|
8
|
+
* @returns {object}
|
|
9
|
+
*/
|
|
10
|
+
static resolve(sceneDescription) {
|
|
11
|
+
const detail = sceneDescription?.detail || {}
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
!PcbScene3dCopperDetailFilter.#usesRealisticMasking(
|
|
15
|
+
sceneDescription
|
|
16
|
+
)
|
|
17
|
+
) {
|
|
18
|
+
return detail
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const copperTextMaskMatcher =
|
|
22
|
+
PcbScene3dCopperDetailFilter.#buildCopperTextMaskMatcher(
|
|
23
|
+
sceneDescription
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
...detail,
|
|
28
|
+
tracks: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
|
|
29
|
+
detail.tracks
|
|
30
|
+
),
|
|
31
|
+
arcs: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
|
|
32
|
+
detail.arcs
|
|
33
|
+
),
|
|
34
|
+
copperTexts: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
|
|
35
|
+
detail.copperTexts,
|
|
36
|
+
copperTextMaskMatcher
|
|
37
|
+
),
|
|
38
|
+
vias: PcbScene3dCopperDetailFilter.#filterExposedVias(detail.vias)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks whether standalone via annuli should be rendered.
|
|
44
|
+
* @param {object} sceneDescription 3D scene description.
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
static shouldRenderStandaloneVias(sceneDescription) {
|
|
48
|
+
if (
|
|
49
|
+
!PcbScene3dCopperDetailFilter.#usesRealisticMasking(
|
|
50
|
+
sceneDescription
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
PcbScene3dCopperDetailFilter.resolveStandaloneVias(sceneDescription)
|
|
58
|
+
.length > 0
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves through-board via barrels that should be visible in the scene.
|
|
64
|
+
* @param {object} sceneDescription 3D scene description.
|
|
65
|
+
* @returns {any[]}
|
|
66
|
+
*/
|
|
67
|
+
static resolveStandaloneVias(sceneDescription) {
|
|
68
|
+
const detail = sceneDescription?.detail || {}
|
|
69
|
+
const vias = PcbScene3dCopperDetailFilter.#usesRealisticMasking(
|
|
70
|
+
sceneDescription
|
|
71
|
+
)
|
|
72
|
+
? PcbScene3dCopperDetailFilter.#filterExposedVias(detail.vias)
|
|
73
|
+
: detail.vias || []
|
|
74
|
+
|
|
75
|
+
return PcbScene3dCopperDetailFilter.#appendPadBarrelSpecs(
|
|
76
|
+
vias,
|
|
77
|
+
detail.pads
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Checks whether one scene should use solder-mask visibility.
|
|
83
|
+
* @param {object} sceneDescription 3D scene description.
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
static #usesRealisticMasking(sceneDescription) {
|
|
87
|
+
const sourceFormat = String(sceneDescription?.sourceFormat || '')
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
|
|
91
|
+
if (sourceFormat === 'altium' || sourceFormat === 'kicad') {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (sceneDescription?.coordinateSystem === 'kicad-3d-y-up') {
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return PcbScene3dCopperDetailFilter.#hasExplicitMaskMetadata(
|
|
100
|
+
sceneDescription?.detail
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Checks whether parsed copper detail carries explicit solder-mask data.
|
|
106
|
+
* @param {object | undefined} detail Scene detail.
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
static #hasExplicitMaskMetadata(detail) {
|
|
110
|
+
return [
|
|
111
|
+
detail?.tracks,
|
|
112
|
+
detail?.arcs,
|
|
113
|
+
detail?.copperTexts,
|
|
114
|
+
detail?.vias,
|
|
115
|
+
detail?.pads
|
|
116
|
+
].some((primitives) =>
|
|
117
|
+
(primitives || []).some((primitive) =>
|
|
118
|
+
PcbScene3dCopperDetailFilter.#hasMaskMetadata(primitive)
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Keeps copper primitives only when the source declares a mask opening.
|
|
125
|
+
* @param {any[] | undefined} primitives Copper primitive list.
|
|
126
|
+
* @param {((primitive: object) => boolean) | null} [maskMatcher]
|
|
127
|
+
* @returns {any[]}
|
|
128
|
+
*/
|
|
129
|
+
static #filterMaskOpenPrimitives(primitives, maskMatcher = null) {
|
|
130
|
+
return (primitives || []).filter((primitive) =>
|
|
131
|
+
PcbScene3dCopperDetailFilter.#hasMaskOpening(primitive, maskMatcher)
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Keeps vias only when they are explicitly not tented.
|
|
137
|
+
* @param {any[] | undefined} vias Via list.
|
|
138
|
+
* @returns {any[]}
|
|
139
|
+
*/
|
|
140
|
+
static #filterExposedVias(vias) {
|
|
141
|
+
return (vias || []).filter((via) => {
|
|
142
|
+
return via?.isTentingTop === false || via?.isTentingBottom === false
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Appends copper barrels for through-hole pads with copper annuli.
|
|
148
|
+
* @param {any[]} vias Visible via list.
|
|
149
|
+
* @param {any[] | undefined} pads Pad list.
|
|
150
|
+
* @returns {any[]}
|
|
151
|
+
*/
|
|
152
|
+
static #appendPadBarrelSpecs(vias, pads) {
|
|
153
|
+
const output = [...(vias || [])]
|
|
154
|
+
const seen = new Set(
|
|
155
|
+
output.map((via) =>
|
|
156
|
+
PcbScene3dCopperDetailFilter.#platedHoleKey(via)
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for (const pad of pads || []) {
|
|
161
|
+
const barrelSpec =
|
|
162
|
+
PcbScene3dCopperDetailFilter.#resolvePadBarrelSpec(pad)
|
|
163
|
+
if (!barrelSpec) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const key = PcbScene3dCopperDetailFilter.#platedHoleKey(barrelSpec)
|
|
168
|
+
if (seen.has(key)) {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
seen.add(key)
|
|
173
|
+
output.push(barrelSpec)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return output
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolves one visible through-hole pad barrel spec.
|
|
181
|
+
* @param {any} pad Pad primitive.
|
|
182
|
+
* @returns {{ x: number, y: number, holeDiameter: number, barrelOnly: true } | null}
|
|
183
|
+
*/
|
|
184
|
+
static #resolvePadBarrelSpec(pad) {
|
|
185
|
+
const holeDiameter = Number(pad?.holeDiameter || 0)
|
|
186
|
+
const holeSlotLength = Number(pad?.holeSlotLength || 0)
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
holeDiameter <= 0 ||
|
|
190
|
+
holeSlotLength > holeDiameter + 0.001 ||
|
|
191
|
+
!PcbScene3dCopperDetailFilter.#hasPadCopperAnnulus(
|
|
192
|
+
pad,
|
|
193
|
+
holeDiameter
|
|
194
|
+
) ||
|
|
195
|
+
(pad?.hasTopSolderMaskOpening === false &&
|
|
196
|
+
pad?.hasBottomSolderMaskOpening === false)
|
|
197
|
+
) {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
x: Number(pad?.x || 0),
|
|
203
|
+
y: Number(pad?.y || 0),
|
|
204
|
+
holeDiameter,
|
|
205
|
+
barrelOnly: true
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Checks whether one pad has copper larger than its drill aperture.
|
|
211
|
+
* @param {any} pad Pad primitive.
|
|
212
|
+
* @param {number} holeDiameter Drill diameter.
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
static #hasPadCopperAnnulus(pad, holeDiameter) {
|
|
216
|
+
return [
|
|
217
|
+
pad?.sizeTopX,
|
|
218
|
+
pad?.sizeTopY,
|
|
219
|
+
pad?.sizeMidX,
|
|
220
|
+
pad?.sizeMidY,
|
|
221
|
+
pad?.sizeBottomX,
|
|
222
|
+
pad?.sizeBottomY
|
|
223
|
+
].some((size) => Number(size || 0) > holeDiameter + 0.001)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Builds a stable dedupe key for one plated through-hole.
|
|
228
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number }} primitive
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
static #platedHoleKey(primitive) {
|
|
232
|
+
return [
|
|
233
|
+
Number(primitive?.x || 0).toFixed(4),
|
|
234
|
+
Number(primitive?.y || 0).toFixed(4),
|
|
235
|
+
Number(primitive?.holeDiameter || 0).toFixed(4)
|
|
236
|
+
].join(':')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Checks whether a copper primitive should break through solder mask.
|
|
241
|
+
* @param {object} primitive Copper primitive.
|
|
242
|
+
* @param {((primitive: object) => boolean) | null} [maskMatcher]
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
static #hasMaskOpening(primitive, maskMatcher = null) {
|
|
246
|
+
if (primitive?.hasSolderMask === true) {
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (primitive?.solderMaskOpening === true) {
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
Number.isFinite(Number(primitive?.solderMaskExpansion)) &&
|
|
256
|
+
Number(primitive?.solderMaskExpansion) !== 0
|
|
257
|
+
) {
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return typeof maskMatcher === 'function'
|
|
262
|
+
? maskMatcher(primitive)
|
|
263
|
+
: false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Checks whether one primitive declares any solder-mask visibility field.
|
|
268
|
+
* @param {object} primitive Copper primitive.
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
static #hasMaskMetadata(primitive) {
|
|
272
|
+
return [
|
|
273
|
+
'hasSolderMask',
|
|
274
|
+
'solderMaskOpening',
|
|
275
|
+
'solderMaskExpansion',
|
|
276
|
+
'hasTopSolderMaskOpening',
|
|
277
|
+
'hasBottomSolderMaskOpening',
|
|
278
|
+
'isTentingTop',
|
|
279
|
+
'isTentingBottom'
|
|
280
|
+
].some((fieldName) =>
|
|
281
|
+
Object.prototype.hasOwnProperty.call(primitive || {}, fieldName)
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Builds a same-position lookup for KiCad mask-layer text openings.
|
|
287
|
+
* @param {object} sceneDescription Scene description.
|
|
288
|
+
* @returns {((primitive: object) => boolean) | null}
|
|
289
|
+
*/
|
|
290
|
+
static #buildCopperTextMaskMatcher(sceneDescription) {
|
|
291
|
+
if (
|
|
292
|
+
!PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription) ||
|
|
293
|
+
!Array.isArray(sceneDescription?.texts)
|
|
294
|
+
) {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const maskTextKeys = new Set()
|
|
299
|
+
sceneDescription.texts
|
|
300
|
+
.filter((text) =>
|
|
301
|
+
PcbScene3dCopperDetailFilter.#isMaskLayerText(text)
|
|
302
|
+
)
|
|
303
|
+
.forEach((text) => {
|
|
304
|
+
PcbScene3dCopperDetailFilter.#textMatchKeys(
|
|
305
|
+
text,
|
|
306
|
+
sceneDescription
|
|
307
|
+
).forEach((key) => maskTextKeys.add(key))
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
if (!maskTextKeys.size) {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (primitive) =>
|
|
315
|
+
maskTextKeys.has(
|
|
316
|
+
PcbScene3dCopperDetailFilter.#textMatchKey(
|
|
317
|
+
primitive,
|
|
318
|
+
primitive?.y
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Checks whether one scene uses KiCad scene coordinate conventions.
|
|
325
|
+
* @param {object} sceneDescription Scene description.
|
|
326
|
+
* @returns {boolean}
|
|
327
|
+
*/
|
|
328
|
+
static #isKiCadScene(sceneDescription) {
|
|
329
|
+
return (
|
|
330
|
+
String(sceneDescription?.sourceFormat || '')
|
|
331
|
+
.trim()
|
|
332
|
+
.toLowerCase() === 'kicad' ||
|
|
333
|
+
sceneDescription?.coordinateSystem === 'kicad-3d-y-up'
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Checks whether one text primitive belongs to a solder-mask layer.
|
|
339
|
+
* @param {object} text Text primitive.
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
static #isMaskLayerText(text) {
|
|
343
|
+
const layer = String(text?.layer || '')
|
|
344
|
+
.trim()
|
|
345
|
+
.toUpperCase()
|
|
346
|
+
return layer === 'F.MASK' || layer === 'B.MASK'
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Converts scene text Y back to source board Y for KiCad y-up scenes.
|
|
351
|
+
* @param {object} text Scene text primitive.
|
|
352
|
+
* @param {object} sceneDescription Scene description.
|
|
353
|
+
* @returns {number}
|
|
354
|
+
*/
|
|
355
|
+
static #sourceYForSceneText(text, sceneDescription) {
|
|
356
|
+
const y = Number(text?.y || 0)
|
|
357
|
+
const centerY = Number(sceneDescription?.board?.centerY)
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
sceneDescription?.coordinateSystem === 'kicad-3d-y-up' &&
|
|
361
|
+
Number.isFinite(centerY)
|
|
362
|
+
) {
|
|
363
|
+
return centerY * 2 - y
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return y
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Builds tolerant keys for matching paired KiCad copper and mask text.
|
|
371
|
+
* @param {object} text Text primitive.
|
|
372
|
+
* @param {object} sceneDescription Scene description.
|
|
373
|
+
* @returns {string[]}
|
|
374
|
+
*/
|
|
375
|
+
static #textMatchKeys(text, sceneDescription) {
|
|
376
|
+
return PcbScene3dCopperDetailFilter.#uniqueValues([
|
|
377
|
+
text?.y,
|
|
378
|
+
PcbScene3dCopperDetailFilter.#sourceYForSceneText(
|
|
379
|
+
text,
|
|
380
|
+
sceneDescription
|
|
381
|
+
)
|
|
382
|
+
]).flatMap((y) =>
|
|
383
|
+
PcbScene3dCopperDetailFilter.#uniqueValues([
|
|
384
|
+
text?.rotation,
|
|
385
|
+
PcbScene3dCopperDetailFilter.#sourceRotationForSceneText(
|
|
386
|
+
text,
|
|
387
|
+
sceneDescription
|
|
388
|
+
)
|
|
389
|
+
]).map((rotation) =>
|
|
390
|
+
PcbScene3dCopperDetailFilter.#textMatchKey(text, y, rotation)
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Builds a tolerant key for matching paired KiCad copper and mask text.
|
|
397
|
+
* @param {object} text Text primitive.
|
|
398
|
+
* @param {number | string | undefined} y Candidate Y coordinate.
|
|
399
|
+
* @param {number | string | undefined} rotation Candidate rotation.
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
static #textMatchKey(text, y, rotation = text?.rotation) {
|
|
403
|
+
return [
|
|
404
|
+
PcbScene3dCopperDetailFilter.#textSide(text),
|
|
405
|
+
PcbScene3dCopperDetailFilter.#roundCoordinate(text?.x),
|
|
406
|
+
PcbScene3dCopperDetailFilter.#roundCoordinate(y),
|
|
407
|
+
PcbScene3dCopperDetailFilter.#roundCoordinate(rotation),
|
|
408
|
+
text?.mirrored ? 'mirrored' : 'normal',
|
|
409
|
+
String(text?.value ?? text?.text ?? '').trim()
|
|
410
|
+
].join('|')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Converts scene text rotation back to source board rotation.
|
|
415
|
+
* @param {object} text Scene text primitive.
|
|
416
|
+
* @param {object} sceneDescription Scene description.
|
|
417
|
+
* @returns {number}
|
|
418
|
+
*/
|
|
419
|
+
static #sourceRotationForSceneText(text, sceneDescription) {
|
|
420
|
+
const rotation = Number(text?.rotation || 0)
|
|
421
|
+
|
|
422
|
+
if (sceneDescription?.coordinateSystem === 'kicad-3d-y-up') {
|
|
423
|
+
return ((-rotation % 360) + 360) % 360
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return rotation
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Returns unique candidates after coordinate rounding.
|
|
431
|
+
* @param {Array<number | string | undefined>} values Candidate values.
|
|
432
|
+
* @returns {Array<number | string | undefined>}
|
|
433
|
+
*/
|
|
434
|
+
static #uniqueValues(values) {
|
|
435
|
+
const seen = new Set()
|
|
436
|
+
const output = []
|
|
437
|
+
|
|
438
|
+
values.forEach((value) => {
|
|
439
|
+
const key = PcbScene3dCopperDetailFilter.#roundCoordinate(value)
|
|
440
|
+
if (seen.has(key)) {
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
seen.add(key)
|
|
445
|
+
output.push(value)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
return output
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Resolves one text primitive to the app's top/bottom side names.
|
|
453
|
+
* @param {object} text Text primitive.
|
|
454
|
+
* @returns {'top' | 'bottom' | ''}
|
|
455
|
+
*/
|
|
456
|
+
static #textSide(text) {
|
|
457
|
+
const layer = String(text?.layer || '')
|
|
458
|
+
.trim()
|
|
459
|
+
.toUpperCase()
|
|
460
|
+
if (layer.startsWith('B.')) {
|
|
461
|
+
return 'bottom'
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (layer.startsWith('F.')) {
|
|
465
|
+
return 'top'
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const side = String(text?.side || '')
|
|
469
|
+
.trim()
|
|
470
|
+
.toLowerCase()
|
|
471
|
+
if (side === 'back' || side === 'bottom') {
|
|
472
|
+
return 'bottom'
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (side === 'front' || side === 'top') {
|
|
476
|
+
return 'top'
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return ''
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Rounds coordinates to avoid float noise in KiCad mm-to-mil conversion.
|
|
484
|
+
* @param {number | string | undefined} value Coordinate value.
|
|
485
|
+
* @returns {number}
|
|
486
|
+
*/
|
|
487
|
+
static #roundCoordinate(value) {
|
|
488
|
+
return Math.round(Number(value || 0) * 1000) / 1000
|
|
489
|
+
}
|
|
490
|
+
}
|