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.
- package/README.md +46 -21
- package/docs/api.md +109 -8
- package/docs/circuitjson.md +143 -29
- package/docs/model-format.md +50 -7
- package/docs/release-notes-v1.2.0.md +122 -0
- package/docs/testing.md +30 -1
- package/package.json +6 -3
- package/spec/library-scope.md +10 -2
- package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
- package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
- package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
- package/src/PcbModelArchiveExporter.mjs +28 -112
- package/src/PcbModelArchiveSourceBundle.mjs +326 -0
- package/src/PcbScene3dAabbIndex.mjs +464 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
- package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
- package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
- package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
- package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
- package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
- package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
- package/src/PcbScene3dController.mjs +40 -34
- package/src/PcbScene3dCopperFactory.mjs +36 -22
- package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
- package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
- package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
- package/src/PcbScene3dCopperTextFactory.mjs +12 -4
- package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
- package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
- package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
- package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
- package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
- package/src/PcbScene3dDrillPathFactory.mjs +86 -16
- package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
- package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
- package/src/PcbScene3dExternalModels.mjs +23 -24
- package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
- package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
- package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
- package/src/PcbScene3dModelContent.mjs +236 -0
- package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
- package/src/PcbScene3dModelIdentity.mjs +106 -0
- package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
- package/src/PcbScene3dPreparedPolygon.mjs +709 -0
- package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
- package/src/PcbScene3dRuntime.mjs +45 -45
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
- package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
- package/src/PcbScene3dShellRenderer.mjs +75 -6
- package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
- package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
- package/src/PcbScene3dStepLoader.mjs +11 -10
- package/src/PcbScene3dText.mjs +1 -1
- 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)
|