pcb-scene3d-viewer 1.1.49 → 1.2.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 (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -0,0 +1,304 @@
1
+ import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
2
+
3
+ const FETCH_SCOPE = Symbol('PcbScene3dModelFetchPolicy.scope')
4
+ const DEFAULT_MAX_RESOURCE_BYTES = 128 * 1024 * 1024
5
+ const DEFAULT_MAX_RESOURCE_COUNT = 256
6
+ const DEFAULT_MAX_TOTAL_BYTES = 512 * 1024 * 1024
7
+
8
+ /**
9
+ * Owns one explicit, origin-aware, bounded model network policy.
10
+ */
11
+ export class PcbScene3dModelFetchPolicy {
12
+ /**
13
+ * Creates or reuses one request-scoped policy and aggregate budget.
14
+ * @param {object} [options] Caller model loading options.
15
+ * @returns {object} Descriptor-safe scoped options.
16
+ */
17
+ static scope(options = {}) {
18
+ if (PcbScene3dModelFetchPolicy.#budget(options)) return options
19
+ const scoped = PcbScene3dDescriptorSafeRecord.copy(options)
20
+ Object.defineProperty(scoped, FETCH_SCOPE, {
21
+ configurable: false,
22
+ enumerable: false,
23
+ value: {
24
+ count: 0,
25
+ totalBytes: 0,
26
+ maxBytes: PcbScene3dModelFetchPolicy.#limit(
27
+ scoped.maxModelBytes,
28
+ DEFAULT_MAX_RESOURCE_BYTES
29
+ ),
30
+ maxCount: PcbScene3dModelFetchPolicy.#limit(
31
+ scoped.maxModelResources,
32
+ DEFAULT_MAX_RESOURCE_COUNT
33
+ ),
34
+ maxTotalBytes: PcbScene3dModelFetchPolicy.#limit(
35
+ scoped.maxModelTotalBytes,
36
+ DEFAULT_MAX_TOTAL_BYTES
37
+ )
38
+ },
39
+ writable: false
40
+ })
41
+ return scoped
42
+ }
43
+
44
+ /**
45
+ * Returns whether an explicit or opted-in fetch implementation exists.
46
+ * @param {object} options Model loading options.
47
+ * @returns {boolean}
48
+ */
49
+ static canFetch(options) {
50
+ return Boolean(PcbScene3dModelFetchPolicy.#fetcher(options))
51
+ }
52
+
53
+ /**
54
+ * Fetches one model resource through origin and resource limits.
55
+ * @param {string} url Resource URL.
56
+ * @param {object} options Scoped model loading options.
57
+ * @param {{ mainUrl?: string, label?: string }} [context] Request context.
58
+ * @returns {Promise<Uint8Array>} Bounded fetched bytes.
59
+ */
60
+ static async fetchBytes(url, options, context = {}) {
61
+ const scoped = PcbScene3dModelFetchPolicy.scope(options)
62
+ const fetcher = PcbScene3dModelFetchPolicy.#fetcher(scoped)
63
+ if (!fetcher) throw new Error('Model network loading is disabled.')
64
+ const budget = PcbScene3dModelFetchPolicy.#budget(scoped)
65
+ PcbScene3dModelFetchPolicy.#begin(budget)
66
+
67
+ const mainUrl = String(context.mainUrl || url || '').trim()
68
+ const sameOrigin = PcbScene3dModelFetchPolicy.#sameOrigin(url, mainUrl)
69
+ const headers = PcbScene3dModelFetchPolicy.#headers(
70
+ url,
71
+ scoped,
72
+ mainUrl,
73
+ sameOrigin,
74
+ context.label
75
+ )
76
+ const controller =
77
+ typeof AbortController === 'function' ? new AbortController() : null
78
+ const timeoutMs = Math.max(Number(scoped.fetchTimeoutMs || 30_000), 1)
79
+ const timeout = controller
80
+ ? setTimeout(() => controller.abort(), timeoutMs)
81
+ : null
82
+ try {
83
+ const response = await fetcher(url, {
84
+ headers,
85
+ signal: controller?.signal
86
+ })
87
+ if (response?.ok === false) {
88
+ throw new Error(
89
+ 'Model fetch failed with HTTP status ' +
90
+ String(response.status || 'unknown') +
91
+ '.'
92
+ )
93
+ }
94
+ PcbScene3dModelFetchPolicy.#assertDeclaredLength(response, budget)
95
+ const bytes =
96
+ await PcbScene3dModelFetchPolicy.#responseBytes(response)
97
+ PcbScene3dModelFetchPolicy.#accept(budget, bytes.byteLength)
98
+ return bytes
99
+ } catch (error) {
100
+ if (error?.name === 'AbortError') {
101
+ throw new Error(
102
+ 'Model fetch timed out after ' + timeoutMs + 'ms: ' + url
103
+ )
104
+ }
105
+ throw error
106
+ } finally {
107
+ if (timeout) clearTimeout(timeout)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Reserves one resource slot before issuing a request.
113
+ * @param {object} budget Shared request budget.
114
+ * @returns {void}
115
+ */
116
+ static #begin(budget) {
117
+ if (budget.count >= budget.maxCount) {
118
+ throw new Error(
119
+ 'Model fetch exceeds maximum model resource count of ' +
120
+ budget.maxCount +
121
+ '.'
122
+ )
123
+ }
124
+ budget.count += 1
125
+ }
126
+
127
+ /**
128
+ * Accounts one completed response against byte limits.
129
+ * @param {object} budget Shared request budget.
130
+ * @param {number} byteLength Response byte length.
131
+ * @returns {void}
132
+ */
133
+ static #accept(budget, byteLength) {
134
+ if (byteLength > budget.maxBytes) {
135
+ throw new Error(
136
+ 'Model fetch exceeds maximum model resource size of ' +
137
+ budget.maxBytes +
138
+ ' bytes.'
139
+ )
140
+ }
141
+ if (budget.totalBytes + byteLength > budget.maxTotalBytes) {
142
+ throw new Error(
143
+ 'Model fetch exceeds maximum aggregate model size of ' +
144
+ budget.maxTotalBytes +
145
+ ' bytes.'
146
+ )
147
+ }
148
+ budget.totalBytes += byteLength
149
+ }
150
+
151
+ /**
152
+ * Rejects a declared response length before materializing its body.
153
+ * @param {unknown} response Fetch response.
154
+ * @param {object} budget Shared request budget.
155
+ * @returns {void}
156
+ */
157
+ static #assertDeclaredLength(response, budget) {
158
+ let header = null
159
+ try {
160
+ header = response?.headers?.get?.('content-length')
161
+ } catch {
162
+ header = null
163
+ }
164
+ const length = Number(header)
165
+ if (!Number.isFinite(length) || length < 0) return
166
+ PcbScene3dModelFetchPolicy.#accept(
167
+ { ...budget, totalBytes: budget.totalBytes },
168
+ length
169
+ )
170
+ }
171
+
172
+ /**
173
+ * Resolves static same-origin and explicit per-URL headers.
174
+ * @param {string} url Request URL.
175
+ * @param {object} options Scoped loading options.
176
+ * @param {string} mainUrl Main model URL.
177
+ * @param {boolean} sameOrigin Whether request and main URL share an origin.
178
+ * @param {string | undefined} label Request label.
179
+ * @returns {Record<string, string>} Request headers.
180
+ */
181
+ static #headers(url, options, mainUrl, sameOrigin, label) {
182
+ const headers = sameOrigin
183
+ ? PcbScene3dModelFetchPolicy.#headerRecord(options.authHeaders)
184
+ : {}
185
+ if (typeof options.authHeadersForUrl !== 'function') return headers
186
+ const selected = options.authHeadersForUrl(url, {
187
+ label: String(label || 'Model'),
188
+ mainUrl,
189
+ sameOrigin
190
+ })
191
+ return {
192
+ ...headers,
193
+ ...PcbScene3dModelFetchPolicy.#headerRecord(selected)
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Converts a fetch response or direct byte-like value into bytes.
199
+ * @param {unknown} response Fetch result.
200
+ * @returns {Promise<Uint8Array>} Response bytes.
201
+ */
202
+ static async #responseBytes(response) {
203
+ if (response instanceof Uint8Array) return response
204
+ if (response instanceof ArrayBuffer) return new Uint8Array(response)
205
+ if (ArrayBuffer.isView(response)) {
206
+ return new Uint8Array(
207
+ response.buffer,
208
+ response.byteOffset,
209
+ response.byteLength
210
+ )
211
+ }
212
+ if (typeof response?.arrayBuffer === 'function') {
213
+ return new Uint8Array(await response.arrayBuffer())
214
+ }
215
+ if (typeof response?.text === 'function') {
216
+ return new TextEncoder().encode(await response.text())
217
+ }
218
+ throw new Error('Fetched model content is not readable.')
219
+ }
220
+
221
+ /**
222
+ * Returns the configured fetch implementation.
223
+ * @param {object} options Loading options.
224
+ * @returns {((url: string, options: object) => Promise<any>) | null}
225
+ */
226
+ static #fetcher(options) {
227
+ if (typeof options?.fetch === 'function') return options.fetch
228
+ return options?.allowNetworkModelFetch === true &&
229
+ typeof globalThis.fetch === 'function'
230
+ ? globalThis.fetch.bind(globalThis)
231
+ : null
232
+ }
233
+
234
+ /**
235
+ * Returns whether two request paths may share static credentials.
236
+ * @param {string} url Resource URL.
237
+ * @param {string} mainUrl Main model URL.
238
+ * @returns {boolean}
239
+ */
240
+ static #sameOrigin(url, mainUrl) {
241
+ const request = PcbScene3dModelFetchPolicy.#absoluteUrl(url)
242
+ const main = PcbScene3dModelFetchPolicy.#absoluteUrl(mainUrl)
243
+ if (request && main) return request.origin === main.origin
244
+ if (request || main) return String(url) === String(mainUrl)
245
+ return true
246
+ }
247
+
248
+ /**
249
+ * Parses one absolute URL without inventing a base.
250
+ * @param {unknown} value URL candidate.
251
+ * @returns {URL | null}
252
+ */
253
+ static #absoluteUrl(value) {
254
+ try {
255
+ return new URL(String(value || ''))
256
+ } catch {
257
+ return null
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Copies primitive header values without invoking accessors.
263
+ * @param {unknown} value Header candidate.
264
+ * @returns {Record<string, string>}
265
+ */
266
+ static #headerRecord(value) {
267
+ return Object.fromEntries(
268
+ Object.entries(PcbScene3dDescriptorSafeRecord.copy(value))
269
+ .map(([key, field]) => [String(key), String(field)])
270
+ .filter(([key, field]) => key && field)
271
+ )
272
+ }
273
+
274
+ /**
275
+ * Reads the private budget attached to scoped options.
276
+ * @param {unknown} options Options candidate.
277
+ * @returns {object | null}
278
+ */
279
+ static #budget(options) {
280
+ if (!options || typeof options !== 'object') return null
281
+ try {
282
+ return options[FETCH_SCOPE] || null
283
+ } catch {
284
+ return null
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Normalizes a nonnegative safe limit or uses its default.
290
+ * @param {unknown} value Limit candidate.
291
+ * @param {number} fallback Safe default.
292
+ * @returns {number}
293
+ */
294
+ static #limit(value, fallback) {
295
+ if (value === undefined || value === null || value === '') {
296
+ return fallback
297
+ }
298
+ const number = Number(value)
299
+ return Number.isSafeInteger(number) && number >= 0 ? number : fallback
300
+ }
301
+ }
302
+
303
+ Object.freeze(PcbScene3dModelFetchPolicy.prototype)
304
+ Object.freeze(PcbScene3dModelFetchPolicy)
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Resolves collision-safe identities for external model assets.
3
+ */
4
+ export class PcbScene3dModelIdentity {
5
+ /**
6
+ * Resolves a stable identity while preferring exact source paths.
7
+ * @param {unknown} model External model metadata.
8
+ * @returns {string}
9
+ */
10
+ static resolve(model) {
11
+ const format = PcbScene3dModelIdentity.#text(
12
+ PcbScene3dModelIdentity.#ownData(model, 'format')
13
+ ).toLowerCase()
14
+ const path = PcbScene3dModelIdentity.projectPath(model)
15
+ if (path) return ['path', format, path].join('::')
16
+
17
+ const source = PcbScene3dModelIdentity.#ownData(model, 'source')
18
+ const sourceStream = PcbScene3dModelIdentity.#firstText([
19
+ PcbScene3dModelIdentity.#ownData(model, 'sourceStream'),
20
+ PcbScene3dModelIdentity.#ownData(source, 'stream'),
21
+ PcbScene3dModelIdentity.#ownData(source, 'sourceStream')
22
+ ])
23
+ if (sourceStream) {
24
+ return ['stream', format, sourceStream].join('::')
25
+ }
26
+
27
+ const id = PcbScene3dModelIdentity.#firstText([
28
+ PcbScene3dModelIdentity.#ownData(model, 'id'),
29
+ PcbScene3dModelIdentity.#ownData(source, 'id')
30
+ ])
31
+ if (id) return ['id', format, id].join('::')
32
+
33
+ const checksum = PcbScene3dModelIdentity.#text(
34
+ PcbScene3dModelIdentity.#ownData(model, 'checksum')
35
+ )
36
+ const name = PcbScene3dModelIdentity.#text(
37
+ PcbScene3dModelIdentity.#ownData(model, 'name')
38
+ )
39
+ return ['fallback', format, checksum, name].join('::')
40
+ }
41
+
42
+ /**
43
+ * Resolves the exact canonical or retained project-relative model path.
44
+ * @param {unknown} model External model metadata.
45
+ * @returns {string}
46
+ */
47
+ static projectPath(model) {
48
+ const source = PcbScene3dModelIdentity.#ownData(model, 'source')
49
+ return PcbScene3dModelIdentity.#firstText([
50
+ PcbScene3dModelIdentity.#ownData(model, 'projectRelativePath'),
51
+ PcbScene3dModelIdentity.#ownData(model, 'project_relative_path'),
52
+ PcbScene3dModelIdentity.#ownData(model, 'relativePath'),
53
+ PcbScene3dModelIdentity.#ownData(source, 'projectRelativePath'),
54
+ PcbScene3dModelIdentity.#ownData(source, 'project_relative_path'),
55
+ PcbScene3dModelIdentity.#ownData(source, 'relativePath'),
56
+ PcbScene3dModelIdentity.#ownData(source, 'entryName'),
57
+ PcbScene3dModelIdentity.#ownData(model, 'resolvedUrl'),
58
+ PcbScene3dModelIdentity.#ownData(model, 'sourceUrl'),
59
+ PcbScene3dModelIdentity.#ownData(model, 'url'),
60
+ PcbScene3dModelIdentity.#ownData(source, 'url'),
61
+ PcbScene3dModelIdentity.#ownData(source, 'uri')
62
+ ])
63
+ }
64
+
65
+ /**
66
+ * Resolves the first non-empty primitive text value.
67
+ * @param {unknown[]} values Candidate values.
68
+ * @returns {string}
69
+ */
70
+ static #firstText(values) {
71
+ for (const value of values) {
72
+ const text = PcbScene3dModelIdentity.#text(value)
73
+ if (text) return text
74
+ }
75
+ return ''
76
+ }
77
+
78
+ /**
79
+ * Converts a primitive identity value to trimmed text.
80
+ * @param {unknown} value Candidate value.
81
+ * @returns {string}
82
+ */
83
+ static #text(value) {
84
+ return ['string', 'number', 'boolean', 'bigint'].includes(typeof value)
85
+ ? String(value).trim()
86
+ : ''
87
+ }
88
+
89
+ /**
90
+ * Reads one own data property without invoking accessors.
91
+ * @param {unknown} value Record candidate.
92
+ * @param {PropertyKey} key Property key.
93
+ * @returns {unknown}
94
+ */
95
+ static #ownData(value, key) {
96
+ if (!value || typeof value !== 'object') return undefined
97
+ try {
98
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
99
+ return descriptor && Object.hasOwn(descriptor, 'value')
100
+ ? descriptor.value
101
+ : undefined
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,141 @@
1
+ import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
2
+
3
+ /** Resolves the exact board-space drill descriptors that have copper annuli. */
4
+ export class PcbScene3dPlatedDrillSpecResolver {
5
+ static #RECTANGULAR_HOLE_SHAPE = 1
6
+ static #SLOTTED_HOLE_SHAPE = 2
7
+
8
+ /**
9
+ * Resolves deduplicated plated drill descriptors.
10
+ * @param {{ pads?: any[], vias?: any[] }} detail Viewer PCB detail.
11
+ * @returns {{ x: number, y: number, diameter: number, width?: number, height?: number, shape?: 'circle' | 'pill' | 'rect', slotLength?: number | null, rotationDeg?: number | null }[]} Plated drill descriptors.
12
+ */
13
+ static resolve(detail) {
14
+ const platedKeys = new Set()
15
+
16
+ for (const via of detail?.vias || []) {
17
+ const diameter = Number(via?.holeDiameter || 0)
18
+ if (diameter <= 0) continue
19
+ platedKeys.add(
20
+ PcbScene3dPlatedDrillSpecResolver.#key({
21
+ x: Number(via?.x || 0),
22
+ y: Number(via?.y || 0),
23
+ diameter,
24
+ width: diameter,
25
+ height: diameter,
26
+ shape: 'circle',
27
+ slotLength: null,
28
+ rotationDeg: 0
29
+ })
30
+ )
31
+ }
32
+
33
+ for (const pad of detail?.pads || []) {
34
+ const diameter = Number(pad?.holeDiameter || 0)
35
+ if (
36
+ diameter <= 0 ||
37
+ !PcbScene3dPlatedDrillSpecResolver.#hasCopperAnnulus(
38
+ pad,
39
+ diameter
40
+ )
41
+ ) {
42
+ continue
43
+ }
44
+ const holeShape = Number(pad?.holeShape)
45
+ const shape =
46
+ holeShape ===
47
+ PcbScene3dPlatedDrillSpecResolver.#RECTANGULAR_HOLE_SHAPE
48
+ ? 'rect'
49
+ : holeShape ===
50
+ PcbScene3dPlatedDrillSpecResolver.#SLOTTED_HOLE_SHAPE
51
+ ? 'pill'
52
+ : 'circle'
53
+ const width = Number(pad?.holeWidth || diameter)
54
+ const height = Number(pad?.holeHeight || diameter)
55
+ const slotLength =
56
+ shape === 'pill' && Number(pad?.holeSlotLength || 0) > diameter
57
+ ? Number(pad?.holeSlotLength || 0)
58
+ : null
59
+ platedKeys.add(
60
+ PcbScene3dPlatedDrillSpecResolver.#key({
61
+ x: Number(pad?.x || 0),
62
+ y: Number(pad?.y || 0),
63
+ diameter,
64
+ width,
65
+ height,
66
+ shape,
67
+ slotLength,
68
+ rotationDeg:
69
+ shape === 'circle'
70
+ ? 0
71
+ : PcbScene3dPlatedDrillSpecResolver.#normalizeAngle(
72
+ Number(
73
+ pad?.holeRotation ?? pad?.rotation ?? 0
74
+ )
75
+ )
76
+ })
77
+ )
78
+ }
79
+
80
+ return PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail).filter(
81
+ (drillSpec) =>
82
+ platedKeys.has(
83
+ PcbScene3dPlatedDrillSpecResolver.#key(drillSpec)
84
+ )
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Checks whether a through-hole pad has copper beyond its aperture.
90
+ * @param {object} pad Viewer pad detail.
91
+ * @param {number} diameter Drill diameter.
92
+ * @returns {boolean} Whether copper surrounds the drill.
93
+ */
94
+ static #hasCopperAnnulus(pad, diameter) {
95
+ const drillSpan = Math.max(
96
+ diameter,
97
+ Number(pad?.holeWidth || 0),
98
+ Number(pad?.holeHeight || 0),
99
+ Number(pad?.holeSlotLength || 0)
100
+ )
101
+ return [
102
+ pad?.sizeTopX,
103
+ pad?.sizeTopY,
104
+ pad?.sizeMidX,
105
+ pad?.sizeMidY,
106
+ pad?.sizeBottomX,
107
+ pad?.sizeBottomY
108
+ ].some((size) => Number(size || 0) > drillSpan + 0.001)
109
+ }
110
+
111
+ /**
112
+ * Builds a stable drill identity.
113
+ * @param {object} drillSpec Drill descriptor.
114
+ * @returns {string} Stable identity.
115
+ */
116
+ static #key(drillSpec) {
117
+ return [
118
+ Number(drillSpec.x || 0).toFixed(4),
119
+ Number(drillSpec.y || 0).toFixed(4),
120
+ Number(drillSpec.diameter || 0).toFixed(4),
121
+ String(drillSpec.shape || 'circle'),
122
+ Number(drillSpec.width || drillSpec.diameter || 0).toFixed(4),
123
+ Number(drillSpec.height || drillSpec.diameter || 0).toFixed(4),
124
+ Number(drillSpec.slotLength || 0).toFixed(4),
125
+ Number(drillSpec.rotationDeg || 0).toFixed(4)
126
+ ].join(':')
127
+ }
128
+
129
+ /**
130
+ * Normalizes an angle to `[0, 360)`.
131
+ * @param {number} angleDeg Raw angle.
132
+ * @returns {number} Normalized angle.
133
+ */
134
+ static #normalizeAngle(angleDeg) {
135
+ const normalized = Number(angleDeg || 0) % 360
136
+ return normalized < 0 ? normalized + 360 : normalized
137
+ }
138
+ }
139
+
140
+ Object.freeze(PcbScene3dPlatedDrillSpecResolver.prototype)
141
+ Object.freeze(PcbScene3dPlatedDrillSpecResolver)