altium-toolkit 1.1.41 → 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 +113 -19
- package/docs/api.md +224 -18
- package/docs/capabilities.md +65 -0
- package/docs/migration/legacy-001.md +309 -0
- package/docs/migration/legacy-002.md +309 -0
- package/docs/migration/legacy-003.md +309 -0
- package/docs/migration/legacy-004.md +309 -0
- package/docs/migration/legacy-005.md +111 -0
- package/docs/migration.md +20 -0
- package/docs/model-format.md +28 -3
- package/docs/release-notes-v1.2.0.md +147 -0
- package/docs/testing.md +43 -1
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +1 -1
- package/examples/arduino-uno/example.mjs +1 -1
- package/examples/cli-utils.mjs +1 -1
- package/examples/corpus-smoke.mjs +1 -1
- package/examples/inspect-board.mjs +1 -1
- package/examples/library-catalog.mjs +1 -1
- package/examples/validate-library.mjs +1 -1
- package/package.json +20 -5
- package/spec/api-baseline-v1.1.41.json +1 -0
- package/spec/asset-baseline-v1.1.41.json +1 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +14 -4
- package/spec/native-source-manifest-v1.1.41.json +1 -0
- package/src/capabilities.mjs +4 -0
- package/src/convergence/AltiumCircuitJsonProjection.mjs +230 -0
- package/src/convergence/AltiumDocumentBuilder.mjs +167 -0
- package/src/convergence/AltiumExtensionResolver.mjs +98 -0
- package/src/convergence/AltiumProjectDocumentResolver.mjs +324 -0
- package/src/convergence/AltiumSchematicCoordinateProjection.mjs +130 -0
- package/src/convergence/AltiumWorkerClient.mjs +95 -0
- package/src/convergence/Parser.mjs +285 -0
- package/src/convergence/ParserInput.mjs +282 -0
- package/src/convergence/ProjectLoader.mjs +916 -0
- package/src/convergence/SchematicSvgRenderer.mjs +126 -0
- package/src/convergence/ToolkitCapabilities.mjs +49 -0
- package/src/core/circuit-json/CircuitJsonSchematicDocumentGraphicBuilder.mjs +785 -0
- package/src/core/circuit-json/CircuitJsonSchematicGraphicBuilder.mjs +909 -0
- package/src/core/circuit-json/CircuitJsonSchematicImageProjection.mjs +277 -0
- package/src/core/circuit-json/CircuitJsonSchematicStrokeStyle.mjs +47 -0
- package/src/extensions.mjs +11 -0
- package/src/index.mjs +20 -4
- package/src/interaction.mjs +7 -0
- package/src/legacy-netlist-query.mjs +11 -0
- package/src/legacy-parser.mjs +141 -0
- package/src/legacy-renderers.mjs +25 -0
- package/src/legacy-scene3d.mjs +10 -0
- package/src/manufacturing.mjs +4 -0
- package/src/parser.mjs +12 -138
- package/src/project.mjs +11 -0
- package/src/query.mjs +4 -0
- package/src/renderers.mjs +4 -22
- package/src/scene3d.mjs +5 -8
- package/src/simulation.mjs +4 -0
- package/src/styles/renderers.css +27 -0
- package/src/testing.mjs +8 -0
- package/src/workers/parser.worker.mjs +37 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ToolkitAsset,
|
|
6
|
+
ToolkitDiagnostic,
|
|
7
|
+
ToolkitError,
|
|
8
|
+
ToolkitProgress
|
|
9
|
+
} from 'circuitjson-toolkit/parser'
|
|
10
|
+
import {
|
|
11
|
+
ArchiveEntryPath,
|
|
12
|
+
ArchiveLimits,
|
|
13
|
+
ProjectResult
|
|
14
|
+
} from 'circuitjson-toolkit/project'
|
|
15
|
+
|
|
16
|
+
import { AltiumWorkerClient } from './AltiumWorkerClient.mjs'
|
|
17
|
+
import { AltiumDocumentBuilder } from './AltiumDocumentBuilder.mjs'
|
|
18
|
+
import { AltiumProjectDocumentResolver } from './AltiumProjectDocumentResolver.mjs'
|
|
19
|
+
import { Parser } from './Parser.mjs'
|
|
20
|
+
import { ParserInput } from './ParserInput.mjs'
|
|
21
|
+
|
|
22
|
+
const ABORTED_GETTER = Object.getOwnPropertyDescriptor(
|
|
23
|
+
AbortSignal.prototype,
|
|
24
|
+
'aborted'
|
|
25
|
+
)?.get
|
|
26
|
+
const PARSER_OPTION_KEYS = [
|
|
27
|
+
'preserveRaw',
|
|
28
|
+
'decodeAssets',
|
|
29
|
+
'extensions',
|
|
30
|
+
'reports',
|
|
31
|
+
'retainSource',
|
|
32
|
+
'worker',
|
|
33
|
+
'transferInput',
|
|
34
|
+
'signal',
|
|
35
|
+
'onProgress'
|
|
36
|
+
]
|
|
37
|
+
const PARSER_EXTENSION_IDS = new Set([
|
|
38
|
+
'altium.native-model',
|
|
39
|
+
'altium.project-context'
|
|
40
|
+
])
|
|
41
|
+
const PROJECT_EXTENSION_IDS = new Set(['altium.entry-order'])
|
|
42
|
+
const PROJECT_SUFFIXES = new Set(['prjpcb', 'prjscr'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Loads bounded Altium entry collections into canonical project envelopes.
|
|
46
|
+
*/
|
|
47
|
+
export class ProjectLoader {
|
|
48
|
+
/**
|
|
49
|
+
* Loads one project synchronously.
|
|
50
|
+
* @param {Record<string, any>[]} entries Named source entries.
|
|
51
|
+
* @param {Record<string, any>} [options] Common loader options.
|
|
52
|
+
* @returns {Record<string, any>} Canonical project.
|
|
53
|
+
*/
|
|
54
|
+
static load(entries, options = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const normalized = ProjectLoader.#normalizeOptions(options)
|
|
57
|
+
if (normalized.worker === true) {
|
|
58
|
+
throw ProjectLoader.#error(
|
|
59
|
+
'Synchronous Altium project loading cannot use a worker.',
|
|
60
|
+
'ERR_WORKER_SYNC_UNAVAILABLE',
|
|
61
|
+
'unsupported'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
const classified = ProjectLoader.#classify(
|
|
65
|
+
entries,
|
|
66
|
+
normalized.archiveLimits,
|
|
67
|
+
normalized.decodeAssets
|
|
68
|
+
)
|
|
69
|
+
ProjectLoader.#assertCandidates(classified)
|
|
70
|
+
const documents = []
|
|
71
|
+
const diagnostics = []
|
|
72
|
+
const parserOptions = ProjectLoader.#parserOptions(normalized)
|
|
73
|
+
for (const entry of classified.candidates) {
|
|
74
|
+
ProjectLoader.#parseEntry(
|
|
75
|
+
entry,
|
|
76
|
+
parserOptions,
|
|
77
|
+
documents,
|
|
78
|
+
diagnostics
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return ProjectLoader.#result(
|
|
82
|
+
classified,
|
|
83
|
+
AltiumProjectDocumentResolver.resolve(
|
|
84
|
+
classified,
|
|
85
|
+
documents,
|
|
86
|
+
normalized.extensions
|
|
87
|
+
),
|
|
88
|
+
diagnostics,
|
|
89
|
+
normalized
|
|
90
|
+
)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw ProjectLoader.#loadError(error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns a discriminated loader result.
|
|
98
|
+
* @param {Record<string, any>[]} entries Named source entries.
|
|
99
|
+
* @param {Record<string, any>} [options] Common loader options.
|
|
100
|
+
* @returns {{ ok: true, value: Record<string, any> } | { ok: false, error: ToolkitError, diagnostics: object[] }} Loader result.
|
|
101
|
+
*/
|
|
102
|
+
static tryLoad(entries, options = {}) {
|
|
103
|
+
try {
|
|
104
|
+
return { ok: true, value: ProjectLoader.load(entries, options) }
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const normalized = ProjectLoader.#loadError(error)
|
|
107
|
+
const provided = Array.isArray(normalized.details?.diagnostics)
|
|
108
|
+
? normalized.details.diagnostics.map((diagnostic) =>
|
|
109
|
+
ToolkitDiagnostic.create(diagnostic)
|
|
110
|
+
)
|
|
111
|
+
: []
|
|
112
|
+
const diagnostics = provided.length
|
|
113
|
+
? provided
|
|
114
|
+
: [
|
|
115
|
+
ToolkitDiagnostic.create({
|
|
116
|
+
code: normalized.code,
|
|
117
|
+
severity: 'error',
|
|
118
|
+
message: normalized.message,
|
|
119
|
+
source: normalized.source,
|
|
120
|
+
location: normalized.location,
|
|
121
|
+
details: normalized.details
|
|
122
|
+
})
|
|
123
|
+
]
|
|
124
|
+
return { ok: false, error: normalized, diagnostics }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Loads one project asynchronously through direct or worker execution.
|
|
130
|
+
* @param {Record<string, any>[]} entries Named source entries.
|
|
131
|
+
* @param {Record<string, any>} [options] Common loader options.
|
|
132
|
+
* @returns {Promise<Record<string, any>>} Canonical project.
|
|
133
|
+
*/
|
|
134
|
+
static async loadAsync(entries, options = {}) {
|
|
135
|
+
const normalized = ProjectLoader.#normalizeOptions(options)
|
|
136
|
+
ProjectLoader.#assertNotCancelled(normalized.signal)
|
|
137
|
+
const useWorker =
|
|
138
|
+
normalized.worker === true ||
|
|
139
|
+
(normalized.worker === 'auto' &&
|
|
140
|
+
normalized.retainSource !== 'reference' &&
|
|
141
|
+
AltiumWorkerClient.isAvailable())
|
|
142
|
+
if (useWorker) {
|
|
143
|
+
const attempt = await AltiumWorkerClient.loadProjectAttempt(
|
|
144
|
+
entries,
|
|
145
|
+
normalized
|
|
146
|
+
)
|
|
147
|
+
if (attempt.ok) return attempt.value
|
|
148
|
+
if (normalized.worker !== 'auto' || !attempt.unavailable) {
|
|
149
|
+
throw attempt.error
|
|
150
|
+
}
|
|
151
|
+
AltiumWorkerClient.dispose()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let progress = ProjectLoader.#progress(
|
|
155
|
+
normalized,
|
|
156
|
+
{ stage: 'detect', message: 'Classifying project entries.' },
|
|
157
|
+
null
|
|
158
|
+
)
|
|
159
|
+
await ProjectLoader.#yieldToHost(Boolean(normalized.signal))
|
|
160
|
+
ProjectLoader.#assertNotCancelled(normalized.signal)
|
|
161
|
+
const classified = ProjectLoader.#classify(
|
|
162
|
+
entries,
|
|
163
|
+
normalized.archiveLimits,
|
|
164
|
+
normalized.decodeAssets
|
|
165
|
+
)
|
|
166
|
+
ProjectLoader.#assertCandidates(classified)
|
|
167
|
+
progress = ProjectLoader.#progress(
|
|
168
|
+
normalized,
|
|
169
|
+
{
|
|
170
|
+
stage: 'project',
|
|
171
|
+
completed: 0,
|
|
172
|
+
total: classified.candidates.length,
|
|
173
|
+
message: 'Loading Altium project entries.'
|
|
174
|
+
},
|
|
175
|
+
progress
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const documents = []
|
|
179
|
+
const diagnostics = []
|
|
180
|
+
const parserOptions = ProjectLoader.#parserOptions(normalized)
|
|
181
|
+
for (let index = 0; index < classified.candidates.length; index += 1) {
|
|
182
|
+
await ProjectLoader.#yieldToHost(Boolean(normalized.signal))
|
|
183
|
+
ProjectLoader.#assertNotCancelled(normalized.signal)
|
|
184
|
+
const entry = classified.candidates[index]
|
|
185
|
+
ProjectLoader.#parseEntry(
|
|
186
|
+
entry,
|
|
187
|
+
parserOptions,
|
|
188
|
+
documents,
|
|
189
|
+
diagnostics
|
|
190
|
+
)
|
|
191
|
+
progress = ProjectLoader.#progress(
|
|
192
|
+
normalized,
|
|
193
|
+
{
|
|
194
|
+
stage: 'project',
|
|
195
|
+
completed: index + 1,
|
|
196
|
+
total: classified.candidates.length,
|
|
197
|
+
detail: entry.name,
|
|
198
|
+
message: 'Loaded Altium project entry.'
|
|
199
|
+
},
|
|
200
|
+
progress
|
|
201
|
+
)
|
|
202
|
+
ProjectLoader.#assertNotCancelled(normalized.signal)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = ProjectLoader.#result(
|
|
206
|
+
classified,
|
|
207
|
+
AltiumProjectDocumentResolver.resolve(
|
|
208
|
+
classified,
|
|
209
|
+
documents,
|
|
210
|
+
normalized.extensions
|
|
211
|
+
),
|
|
212
|
+
diagnostics,
|
|
213
|
+
normalized
|
|
214
|
+
)
|
|
215
|
+
ProjectLoader.#progress(
|
|
216
|
+
normalized,
|
|
217
|
+
{
|
|
218
|
+
stage: 'complete',
|
|
219
|
+
completed: classified.candidates.length,
|
|
220
|
+
total: classified.candidates.length,
|
|
221
|
+
message: 'Altium project loading complete.'
|
|
222
|
+
},
|
|
223
|
+
progress
|
|
224
|
+
)
|
|
225
|
+
ProjectLoader.#assertNotCancelled(normalized.signal)
|
|
226
|
+
return result
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detects a supported nonempty Altium entry collection.
|
|
231
|
+
* @param {unknown} entries Entry collection candidate.
|
|
232
|
+
* @returns {boolean} Whether at least one entry is supported.
|
|
233
|
+
*/
|
|
234
|
+
static supports(entries) {
|
|
235
|
+
try {
|
|
236
|
+
const classified = ProjectLoader.#classify(
|
|
237
|
+
entries,
|
|
238
|
+
ArchiveLimits.defaults,
|
|
239
|
+
'none'
|
|
240
|
+
)
|
|
241
|
+
return classified.candidates.length > 0
|
|
242
|
+
} catch {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Normalizes common parser and archive options once.
|
|
249
|
+
* @param {unknown} options Options candidate.
|
|
250
|
+
* @returns {Record<string, any>} Normalized options.
|
|
251
|
+
*/
|
|
252
|
+
static #normalizeOptions(options) {
|
|
253
|
+
try {
|
|
254
|
+
const fields = ProjectLoader.#plainFields(
|
|
255
|
+
options,
|
|
256
|
+
'Altium project options must be a plain object.'
|
|
257
|
+
)
|
|
258
|
+
const parserOptions = {}
|
|
259
|
+
for (const key of PARSER_OPTION_KEYS) {
|
|
260
|
+
if (Object.hasOwn(fields, key)) {
|
|
261
|
+
parserOptions[key] = fields[key]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const normalized = ParserInput.normalize(
|
|
265
|
+
{ fileName: 'project.PrjPcb', data: '' },
|
|
266
|
+
parserOptions
|
|
267
|
+
).options
|
|
268
|
+
if (normalized.signal !== undefined && normalized.signal !== null) {
|
|
269
|
+
ProjectLoader.#signalState(normalized.signal)
|
|
270
|
+
}
|
|
271
|
+
ProjectLoader.#assertExtensions(normalized.extensions)
|
|
272
|
+
return {
|
|
273
|
+
...normalized,
|
|
274
|
+
archiveLimits: ArchiveLimits.normalize(fields.archiveLimits)
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw ProjectLoader.#inputError(error)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Validates, measures, and classifies all project entries.
|
|
283
|
+
* @param {unknown} entries Entry candidates.
|
|
284
|
+
* @param {Record<string, number>} limits Archive limits.
|
|
285
|
+
* @param {'none' | 'metadata' | 'full'} assetMode Asset preparation mode.
|
|
286
|
+
* @returns {{ entries: object[], candidates: object[], entryNames: string[], totalBytes: number, projectEntry: object | null }} Classified entries.
|
|
287
|
+
*/
|
|
288
|
+
static #classify(entries, limits, assetMode) {
|
|
289
|
+
const entryDescriptors = ProjectLoader.#entryArray(entries)
|
|
290
|
+
const entryCount = entryDescriptors.length.value
|
|
291
|
+
if (!entryCount) {
|
|
292
|
+
throw ProjectLoader.#inputError(
|
|
293
|
+
new TypeError('Altium project entries must be nonempty.')
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
ProjectLoader.#assertLimit('maxEntries', limits.maxEntries, entryCount)
|
|
297
|
+
|
|
298
|
+
const prepared = []
|
|
299
|
+
let totalBytes = 0
|
|
300
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
301
|
+
const entry = entryDescriptors[String(index)].value
|
|
302
|
+
const fields = ProjectLoader.#entryFields(entry)
|
|
303
|
+
const name = ArchiveEntryPath.normalize(fields.name)
|
|
304
|
+
const byteLength = ProjectLoader.#byteLength(fields.data)
|
|
305
|
+
let entryBytes = byteLength
|
|
306
|
+
ProjectLoader.#assertLimit(
|
|
307
|
+
'maxEntryBytes',
|
|
308
|
+
limits.maxEntryBytes,
|
|
309
|
+
entryBytes,
|
|
310
|
+
name
|
|
311
|
+
)
|
|
312
|
+
totalBytes += byteLength
|
|
313
|
+
ProjectLoader.#assertLimit(
|
|
314
|
+
'maxTotalBytes',
|
|
315
|
+
limits.maxTotalBytes,
|
|
316
|
+
totalBytes
|
|
317
|
+
)
|
|
318
|
+
const archiveDepth = ProjectLoader.#metadataInteger(
|
|
319
|
+
fields.archiveDepth,
|
|
320
|
+
'archiveDepth',
|
|
321
|
+
0
|
|
322
|
+
)
|
|
323
|
+
ProjectLoader.#assertLimit(
|
|
324
|
+
'maxArchiveDepth',
|
|
325
|
+
limits.maxArchiveDepth,
|
|
326
|
+
archiveDepth,
|
|
327
|
+
name
|
|
328
|
+
)
|
|
329
|
+
ProjectLoader.#assertCompressionRatio(
|
|
330
|
+
byteLength,
|
|
331
|
+
fields.compressedByteLength,
|
|
332
|
+
limits.maxCompressionRatio,
|
|
333
|
+
name
|
|
334
|
+
)
|
|
335
|
+
const input = { fileName: name, data: fields.data }
|
|
336
|
+
if (fields.assets !== undefined) {
|
|
337
|
+
try {
|
|
338
|
+
input.assets = ToolkitAsset.prepareAll(fields.assets, {
|
|
339
|
+
mode: assetMode,
|
|
340
|
+
acceptPayload: (assetBytes) => {
|
|
341
|
+
entryBytes += assetBytes
|
|
342
|
+
ProjectLoader.#assertLimit(
|
|
343
|
+
'maxEntryBytes',
|
|
344
|
+
limits.maxEntryBytes,
|
|
345
|
+
entryBytes,
|
|
346
|
+
name
|
|
347
|
+
)
|
|
348
|
+
totalBytes += assetBytes
|
|
349
|
+
ProjectLoader.#assertLimit(
|
|
350
|
+
'maxTotalBytes',
|
|
351
|
+
limits.maxTotalBytes,
|
|
352
|
+
totalBytes,
|
|
353
|
+
name
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error instanceof ToolkitError) throw error
|
|
359
|
+
throw ProjectLoader.#inputError(error)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const fileType = ParserInput.suffix(name)
|
|
363
|
+
prepared.push({
|
|
364
|
+
name,
|
|
365
|
+
byteLength,
|
|
366
|
+
fileType,
|
|
367
|
+
input,
|
|
368
|
+
supported: ParserInput.supportsFileType(fileType),
|
|
369
|
+
isProject: PROJECT_SUFFIXES.has(fileType)
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const entryNames = ArchiveEntryPath.unique(
|
|
374
|
+
prepared.map((entry) => entry.name)
|
|
375
|
+
)
|
|
376
|
+
const candidates = prepared
|
|
377
|
+
.filter((entry) => entry.supported)
|
|
378
|
+
.sort((left, right) =>
|
|
379
|
+
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
380
|
+
)
|
|
381
|
+
const projectEntry = candidates.find((entry) => entry.isProject)
|
|
382
|
+
return {
|
|
383
|
+
entries: prepared,
|
|
384
|
+
candidates,
|
|
385
|
+
entryNames,
|
|
386
|
+
totalBytes,
|
|
387
|
+
projectEntry: projectEntry || null
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Parses one candidate and records deterministic partial failures.
|
|
393
|
+
* @param {Record<string, any>} entry Prepared entry.
|
|
394
|
+
* @param {Record<string, any>} parserOptions Normalized parser options.
|
|
395
|
+
* @param {object[]} documents Successful documents.
|
|
396
|
+
* @param {object[]} diagnostics Project diagnostics.
|
|
397
|
+
* @returns {void}
|
|
398
|
+
*/
|
|
399
|
+
static #parseEntry(entry, parserOptions, documents, diagnostics) {
|
|
400
|
+
try {
|
|
401
|
+
const selectedOptions = ProjectLoader.#entryParserOptions(
|
|
402
|
+
entry,
|
|
403
|
+
parserOptions
|
|
404
|
+
)
|
|
405
|
+
documents.push(
|
|
406
|
+
selectedOptions.reports.length
|
|
407
|
+
? Parser.parse(entry.input, selectedOptions)
|
|
408
|
+
: AltiumDocumentBuilder.build({
|
|
409
|
+
input: {
|
|
410
|
+
fileName: entry.name,
|
|
411
|
+
data: entry.input.data,
|
|
412
|
+
assets: entry.input.assets || []
|
|
413
|
+
},
|
|
414
|
+
sourceReference: entry.input,
|
|
415
|
+
options: selectedOptions
|
|
416
|
+
})
|
|
417
|
+
)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const normalized = ProjectLoader.#loadError(error)
|
|
420
|
+
if (normalized.code === 'ERR_CAPABILITY_UNAVAILABLE') {
|
|
421
|
+
throw normalized
|
|
422
|
+
}
|
|
423
|
+
diagnostics.push(
|
|
424
|
+
ToolkitDiagnostic.create({
|
|
425
|
+
code: normalized.code,
|
|
426
|
+
severity: 'error',
|
|
427
|
+
message: normalized.message,
|
|
428
|
+
source: entry.name,
|
|
429
|
+
location: normalized.location,
|
|
430
|
+
details: {
|
|
431
|
+
category: normalized.category,
|
|
432
|
+
format: normalized.format,
|
|
433
|
+
cause: normalized.cause
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Selects options consumed by the standalone parser.
|
|
442
|
+
* @param {Record<string, any>} options Normalized parser options.
|
|
443
|
+
* @returns {Record<string, any>} Parser options.
|
|
444
|
+
*/
|
|
445
|
+
static #parserOptions(options) {
|
|
446
|
+
const selected = {}
|
|
447
|
+
for (const key of PARSER_OPTION_KEYS) {
|
|
448
|
+
if (key === 'signal' || key === 'onProgress') continue
|
|
449
|
+
if (key === 'worker') selected[key] = false
|
|
450
|
+
else if (key === 'extensions' && Array.isArray(options[key])) {
|
|
451
|
+
selected[key] = options[key].filter((id) =>
|
|
452
|
+
PARSER_EXTENSION_IDS.has(id)
|
|
453
|
+
)
|
|
454
|
+
} else selected[key] = options[key]
|
|
455
|
+
}
|
|
456
|
+
return selected
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Retains compact project context internally until cross-document
|
|
461
|
+
* resolution completes, even when callers omit source extensions.
|
|
462
|
+
* @param {Record<string, any>} entry Prepared entry.
|
|
463
|
+
* @param {Record<string, any>} options Normalized options.
|
|
464
|
+
* @returns {Record<string, any>} Parser options for one entry.
|
|
465
|
+
*/
|
|
466
|
+
static #entryParserOptions(entry, options) {
|
|
467
|
+
const omitsParserExtensions =
|
|
468
|
+
options.extensions === 'none' ||
|
|
469
|
+
(Array.isArray(options.extensions) && !options.extensions.length)
|
|
470
|
+
if (entry.isProject && omitsParserExtensions) {
|
|
471
|
+
return { ...options, extensions: 'canonical' }
|
|
472
|
+
}
|
|
473
|
+
return options
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Rejects unknown explicitly selected project or parser extension ids.
|
|
478
|
+
* @param {string | string[]} extensions Extension selection.
|
|
479
|
+
* @returns {void}
|
|
480
|
+
*/
|
|
481
|
+
static #assertExtensions(extensions) {
|
|
482
|
+
if (!Array.isArray(extensions)) return
|
|
483
|
+
const unknown = extensions.find(
|
|
484
|
+
(id) =>
|
|
485
|
+
!PARSER_EXTENSION_IDS.has(id) && !PROJECT_EXTENSION_IDS.has(id)
|
|
486
|
+
)
|
|
487
|
+
if (!unknown) return
|
|
488
|
+
throw ProjectLoader.#error(
|
|
489
|
+
`Altium project extension is unavailable: ${unknown}.`,
|
|
490
|
+
'ERR_CAPABILITY_UNAVAILABLE',
|
|
491
|
+
'unsupported',
|
|
492
|
+
{ extensions }
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Builds the canonical project envelope.
|
|
498
|
+
* @param {Record<string, any>} classified Classified entries.
|
|
499
|
+
* @param {object[]} documents Successful documents.
|
|
500
|
+
* @param {object[]} diagnostics Project diagnostics.
|
|
501
|
+
* @param {Record<string, any>} options Normalized options.
|
|
502
|
+
* @returns {Record<string, any>} Canonical project.
|
|
503
|
+
*/
|
|
504
|
+
static #result(classified, documents, diagnostics, options) {
|
|
505
|
+
if (!documents.length) {
|
|
506
|
+
throw ProjectLoader.#error(
|
|
507
|
+
'No requested Altium project document could be loaded.',
|
|
508
|
+
'ERR_PROJECT_NO_DOCUMENTS',
|
|
509
|
+
'parse',
|
|
510
|
+
{ diagnostics }
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
return ProjectResult.create({
|
|
514
|
+
source: { format: 'altium', entryNames: classified.entryNames },
|
|
515
|
+
documents,
|
|
516
|
+
project: classified.projectEntry
|
|
517
|
+
? {
|
|
518
|
+
name: classified.projectEntry.name,
|
|
519
|
+
format: 'altium',
|
|
520
|
+
relationships: []
|
|
521
|
+
}
|
|
522
|
+
: null,
|
|
523
|
+
extensions: ProjectLoader.#projectExtension(
|
|
524
|
+
options.extensions,
|
|
525
|
+
classified.entryNames
|
|
526
|
+
),
|
|
527
|
+
assets: ProjectLoader.#companionAssets(
|
|
528
|
+
classified.entries,
|
|
529
|
+
options.decodeAssets
|
|
530
|
+
),
|
|
531
|
+
diagnostics,
|
|
532
|
+
statistics: {
|
|
533
|
+
entryCount: classified.entries.length,
|
|
534
|
+
candidateCount: classified.candidates.length,
|
|
535
|
+
documentCount: documents.length,
|
|
536
|
+
failureCount: diagnostics.length,
|
|
537
|
+
totalBytes: classified.totalBytes
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Applies common extension selection semantics to project-level facts.
|
|
544
|
+
* @param {string | string[]} selection Extension selection.
|
|
545
|
+
* @param {string[]} entryNames Normalized project entry order.
|
|
546
|
+
* @returns {Record<string, any>} Source extension map.
|
|
547
|
+
*/
|
|
548
|
+
static #projectExtension(selection, entryNames) {
|
|
549
|
+
const selected = Array.isArray(selection)
|
|
550
|
+
? selection.includes('altium.entry-order')
|
|
551
|
+
: selection !== 'none'
|
|
552
|
+
if (!selected) return {}
|
|
553
|
+
return {
|
|
554
|
+
altium: {
|
|
555
|
+
$meta: {
|
|
556
|
+
schema: 'ecad-toolkit.extension.v1',
|
|
557
|
+
completeness: Array.isArray(selection)
|
|
558
|
+
? 'canonical'
|
|
559
|
+
: selection,
|
|
560
|
+
included: ['altium.entry-order'],
|
|
561
|
+
omitted: []
|
|
562
|
+
},
|
|
563
|
+
entryNames
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Creates project-level assets for non-Altium entries.
|
|
570
|
+
* @param {object[]} entries Prepared entries.
|
|
571
|
+
* @param {'none' | 'metadata' | 'full'} mode Decode mode.
|
|
572
|
+
* @returns {object[]} Companion assets.
|
|
573
|
+
*/
|
|
574
|
+
static #companionAssets(entries, mode) {
|
|
575
|
+
if (mode === 'none') return []
|
|
576
|
+
return entries
|
|
577
|
+
.filter((entry) => !entry.supported)
|
|
578
|
+
.map((entry) => ({
|
|
579
|
+
kind: 'companion',
|
|
580
|
+
name: entry.name,
|
|
581
|
+
mediaType: 'application/octet-stream',
|
|
582
|
+
byteLength: entry.byteLength,
|
|
583
|
+
data: mode === 'full' ? entry.input.data : null,
|
|
584
|
+
source: { entryName: entry.name }
|
|
585
|
+
}))
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Validates one project entry through data properties only.
|
|
590
|
+
* @param {unknown} entry Entry candidate.
|
|
591
|
+
* @returns {Record<string, any>} Entry fields.
|
|
592
|
+
*/
|
|
593
|
+
static #entryFields(entry) {
|
|
594
|
+
const fields = ProjectLoader.#plainFields(
|
|
595
|
+
entry,
|
|
596
|
+
'Each Altium project entry must be a plain object.'
|
|
597
|
+
)
|
|
598
|
+
if (!Object.hasOwn(fields, 'name') || !Object.hasOwn(fields, 'data')) {
|
|
599
|
+
throw ProjectLoader.#inputError(
|
|
600
|
+
new TypeError('Each project entry requires name and data.')
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
if (fields.assets !== undefined && !Array.isArray(fields.assets)) {
|
|
604
|
+
throw ProjectLoader.#inputError(
|
|
605
|
+
new TypeError('Project entry assets must be an array.')
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
return fields
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Reads an exact dense project-entry array without caller iteration.
|
|
613
|
+
* @param {unknown} entries Project entry collection.
|
|
614
|
+
* @returns {Record<string, PropertyDescriptor>} Own array descriptors.
|
|
615
|
+
*/
|
|
616
|
+
static #entryArray(entries) {
|
|
617
|
+
if (!Array.isArray(entries)) {
|
|
618
|
+
throw ProjectLoader.#inputError(
|
|
619
|
+
new TypeError('Altium project entries must be nonempty.')
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
let prototype
|
|
623
|
+
let descriptors
|
|
624
|
+
try {
|
|
625
|
+
prototype = Object.getPrototypeOf(entries)
|
|
626
|
+
descriptors = Object.getOwnPropertyDescriptors(entries)
|
|
627
|
+
} catch {
|
|
628
|
+
throw ProjectLoader.#inputError(
|
|
629
|
+
new TypeError(
|
|
630
|
+
'Altium project entries must be a dense plain array.'
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
const length = descriptors.length?.value
|
|
635
|
+
if (
|
|
636
|
+
prototype !== Array.prototype ||
|
|
637
|
+
!Number.isSafeInteger(length) ||
|
|
638
|
+
length < 0 ||
|
|
639
|
+
Reflect.ownKeys(descriptors).length !== length + 1
|
|
640
|
+
) {
|
|
641
|
+
throw ProjectLoader.#inputError(
|
|
642
|
+
new TypeError(
|
|
643
|
+
'Altium project entries must be a dense plain array.'
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
for (let index = 0; index < length; index += 1) {
|
|
648
|
+
const descriptor = descriptors[String(index)]
|
|
649
|
+
if (
|
|
650
|
+
!descriptor ||
|
|
651
|
+
!Object.hasOwn(descriptor, 'value') ||
|
|
652
|
+
descriptor.enumerable !== true
|
|
653
|
+
) {
|
|
654
|
+
throw ProjectLoader.#inputError(
|
|
655
|
+
new TypeError(
|
|
656
|
+
'Altium project entries must contain enumerable data properties.'
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return descriptors
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Reads an accessor-free plain record.
|
|
666
|
+
* @param {unknown} value Record candidate.
|
|
667
|
+
* @param {string} message Failure message.
|
|
668
|
+
* @returns {Record<string, any>} Own field values.
|
|
669
|
+
*/
|
|
670
|
+
static #plainFields(value, message) {
|
|
671
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
672
|
+
throw new TypeError(message)
|
|
673
|
+
}
|
|
674
|
+
const prototype = Object.getPrototypeOf(value)
|
|
675
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
676
|
+
throw new TypeError(message)
|
|
677
|
+
}
|
|
678
|
+
const descriptors = Object.getOwnPropertyDescriptors(value)
|
|
679
|
+
const fields = Object.create(null)
|
|
680
|
+
for (const [name, descriptor] of Object.entries(descriptors)) {
|
|
681
|
+
if (!Object.hasOwn(descriptor, 'value')) {
|
|
682
|
+
throw new TypeError(
|
|
683
|
+
'Accessor-backed project fields are invalid.'
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
fields[name] = descriptor.value
|
|
687
|
+
}
|
|
688
|
+
return fields
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Measures a common parser payload without copying binary data.
|
|
693
|
+
* @param {unknown} data Payload candidate.
|
|
694
|
+
* @returns {number} Byte length.
|
|
695
|
+
*/
|
|
696
|
+
static #byteLength(data) {
|
|
697
|
+
if (typeof data === 'string') {
|
|
698
|
+
return ProjectLoader.#stringByteLength(data)
|
|
699
|
+
}
|
|
700
|
+
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
|
701
|
+
return data.byteLength
|
|
702
|
+
}
|
|
703
|
+
throw ProjectLoader.#inputError(
|
|
704
|
+
new TypeError('Project entry data uses an unsupported type.')
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Measures UTF-8 text without allocating an encoded copy.
|
|
710
|
+
* @param {string} value Text value.
|
|
711
|
+
* @returns {number} UTF-8 byte length.
|
|
712
|
+
*/
|
|
713
|
+
static #stringByteLength(value) {
|
|
714
|
+
let byteLength = 0
|
|
715
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
716
|
+
const codeUnit = value.charCodeAt(index)
|
|
717
|
+
if (codeUnit <= 0x7f) byteLength += 1
|
|
718
|
+
else if (codeUnit <= 0x7ff) byteLength += 2
|
|
719
|
+
else if (
|
|
720
|
+
codeUnit >= 0xd800 &&
|
|
721
|
+
codeUnit <= 0xdbff &&
|
|
722
|
+
index + 1 < value.length &&
|
|
723
|
+
value.charCodeAt(index + 1) >= 0xdc00 &&
|
|
724
|
+
value.charCodeAt(index + 1) <= 0xdfff
|
|
725
|
+
) {
|
|
726
|
+
byteLength += 4
|
|
727
|
+
index += 1
|
|
728
|
+
} else byteLength += 3
|
|
729
|
+
}
|
|
730
|
+
return byteLength
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Normalizes optional nonnegative integer metadata.
|
|
735
|
+
* @param {unknown} value Metadata value.
|
|
736
|
+
* @param {string} key Field name.
|
|
737
|
+
* @param {number} fallback Missing fallback.
|
|
738
|
+
* @returns {number} Normalized value.
|
|
739
|
+
*/
|
|
740
|
+
static #metadataInteger(value, key, fallback) {
|
|
741
|
+
if (value === undefined) return fallback
|
|
742
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
743
|
+
throw ProjectLoader.#inputError(
|
|
744
|
+
new TypeError(`${key} must be a nonnegative safe integer.`)
|
|
745
|
+
)
|
|
746
|
+
}
|
|
747
|
+
return value
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Enforces compressed-size metadata.
|
|
752
|
+
* @param {number} byteLength Uncompressed bytes.
|
|
753
|
+
* @param {unknown} compressedValue Compressed bytes.
|
|
754
|
+
* @param {number} maximum Maximum ratio.
|
|
755
|
+
* @param {string} entryName Entry name.
|
|
756
|
+
* @returns {void}
|
|
757
|
+
*/
|
|
758
|
+
static #assertCompressionRatio(
|
|
759
|
+
byteLength,
|
|
760
|
+
compressedValue,
|
|
761
|
+
maximum,
|
|
762
|
+
entryName
|
|
763
|
+
) {
|
|
764
|
+
if (compressedValue === undefined) return
|
|
765
|
+
const compressed = ProjectLoader.#metadataInteger(
|
|
766
|
+
compressedValue,
|
|
767
|
+
'compressedByteLength',
|
|
768
|
+
0
|
|
769
|
+
)
|
|
770
|
+
const ratio =
|
|
771
|
+
compressed === 0
|
|
772
|
+
? byteLength === 0
|
|
773
|
+
? 1
|
|
774
|
+
: Number.POSITIVE_INFINITY
|
|
775
|
+
: byteLength / compressed
|
|
776
|
+
ProjectLoader.#assertLimit(
|
|
777
|
+
'maxCompressionRatio',
|
|
778
|
+
maximum,
|
|
779
|
+
ratio,
|
|
780
|
+
entryName
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Enforces one archive limit.
|
|
786
|
+
* @param {string} limit Limit name.
|
|
787
|
+
* @param {number} maximum Maximum value.
|
|
788
|
+
* @param {number} actual Actual value.
|
|
789
|
+
* @param {string} [entryName] Entry name.
|
|
790
|
+
* @returns {void}
|
|
791
|
+
*/
|
|
792
|
+
static #assertLimit(limit, maximum, actual, entryName = '') {
|
|
793
|
+
if (actual <= maximum) return
|
|
794
|
+
throw new ToolkitError(`Archive limit exceeded: ${limit}.`, {
|
|
795
|
+
code: 'ERR_ARCHIVE_LIMIT_EXCEEDED',
|
|
796
|
+
category: 'validation',
|
|
797
|
+
format: 'archive',
|
|
798
|
+
source: entryName,
|
|
799
|
+
details: { limit, maximum, actual, entryName }
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** @param {Record<string, any>} classified Classification. @returns {void} */
|
|
804
|
+
static #assertCandidates(classified) {
|
|
805
|
+
if (classified.candidates.length) return
|
|
806
|
+
throw ProjectLoader.#error(
|
|
807
|
+
'No supported Altium project entry was found.',
|
|
808
|
+
'ERR_PROJECT_UNSUPPORTED',
|
|
809
|
+
'unsupported'
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Emits one ordered project progress row.
|
|
815
|
+
* @param {Record<string, any>} options Normalized options.
|
|
816
|
+
* @param {Record<string, any>} fields Progress fields.
|
|
817
|
+
* @param {Record<string, any> | null} previous Previous row.
|
|
818
|
+
* @returns {Record<string, any> | null} Current row.
|
|
819
|
+
*/
|
|
820
|
+
static #progress(options, fields, previous) {
|
|
821
|
+
if (!options.onProgress) return previous
|
|
822
|
+
const row = ToolkitProgress.create(fields, previous)
|
|
823
|
+
options.onProgress(row)
|
|
824
|
+
return row
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** @param {boolean} cancellationResponsive Timer-yield mode. @returns {Promise<void>} Yield. */
|
|
828
|
+
static async #yieldToHost(cancellationResponsive) {
|
|
829
|
+
if (cancellationResponsive) {
|
|
830
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
if (typeof globalThis.scheduler?.yield === 'function') {
|
|
834
|
+
await globalThis.scheduler.yield()
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if (typeof setImmediate === 'function') {
|
|
838
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
if (typeof globalThis.MessageChannel === 'function') {
|
|
842
|
+
await new Promise((resolve) => {
|
|
843
|
+
const channel = new globalThis.MessageChannel()
|
|
844
|
+
channel.port1.onmessage = () => {
|
|
845
|
+
channel.port1.close()
|
|
846
|
+
channel.port2.close()
|
|
847
|
+
resolve()
|
|
848
|
+
}
|
|
849
|
+
channel.port2.postMessage(null)
|
|
850
|
+
})
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** @param {unknown} signal Abort signal. @returns {void} */
|
|
857
|
+
static #assertNotCancelled(signal) {
|
|
858
|
+
if (signal === undefined || signal === null) return
|
|
859
|
+
if (!ProjectLoader.#signalState(signal)) return
|
|
860
|
+
throw ProjectLoader.#error(
|
|
861
|
+
'Altium project loading was cancelled.',
|
|
862
|
+
'ERR_CANCELLED',
|
|
863
|
+
'cancelled'
|
|
864
|
+
)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** @param {unknown} signal Abort signal. @returns {boolean} Aborted state. */
|
|
868
|
+
static #signalState(signal) {
|
|
869
|
+
if (!ABORTED_GETTER) {
|
|
870
|
+
throw new TypeError('AbortSignal state is unavailable.')
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
return Boolean(Reflect.apply(ABORTED_GETTER, signal, []))
|
|
874
|
+
} catch {
|
|
875
|
+
throw new TypeError('Project signal must be an AbortSignal.')
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** @param {unknown} error Failure. @returns {ToolkitError} Input error. */
|
|
880
|
+
static #inputError(error) {
|
|
881
|
+
return ToolkitError.from(error, {
|
|
882
|
+
code: 'ERR_PROJECT_INPUT',
|
|
883
|
+
category: 'validation',
|
|
884
|
+
format: 'altium'
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** @param {unknown} error Failure. @returns {ToolkitError} Typed error. */
|
|
889
|
+
static #loadError(error) {
|
|
890
|
+
return ToolkitError.from(error, {
|
|
891
|
+
code: 'ERR_PROJECT_LOAD',
|
|
892
|
+
category: 'runtime',
|
|
893
|
+
format: 'altium'
|
|
894
|
+
})
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Creates one typed project failure.
|
|
899
|
+
* @param {string} message Message.
|
|
900
|
+
* @param {string} code Stable code.
|
|
901
|
+
* @param {string} category Error category.
|
|
902
|
+
* @param {Record<string, any>} [details] Error details.
|
|
903
|
+
* @returns {ToolkitError} Typed failure.
|
|
904
|
+
*/
|
|
905
|
+
static #error(message, code, category, details = {}) {
|
|
906
|
+
return new ToolkitError(message, {
|
|
907
|
+
code,
|
|
908
|
+
category,
|
|
909
|
+
format: 'altium',
|
|
910
|
+
details
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
Object.freeze(ProjectLoader.prototype)
|
|
916
|
+
Object.freeze(ProjectLoader)
|