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.
Files changed (58) hide show
  1. package/README.md +113 -19
  2. package/docs/api.md +224 -18
  3. package/docs/capabilities.md +65 -0
  4. package/docs/migration/legacy-001.md +309 -0
  5. package/docs/migration/legacy-002.md +309 -0
  6. package/docs/migration/legacy-003.md +309 -0
  7. package/docs/migration/legacy-004.md +309 -0
  8. package/docs/migration/legacy-005.md +111 -0
  9. package/docs/migration.md +20 -0
  10. package/docs/model-format.md +28 -3
  11. package/docs/release-notes-v1.2.0.md +147 -0
  12. package/docs/testing.md +43 -1
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +1 -1
  14. package/examples/arduino-uno/example.mjs +1 -1
  15. package/examples/cli-utils.mjs +1 -1
  16. package/examples/corpus-smoke.mjs +1 -1
  17. package/examples/inspect-board.mjs +1 -1
  18. package/examples/library-catalog.mjs +1 -1
  19. package/examples/validate-library.mjs +1 -1
  20. package/package.json +20 -5
  21. package/spec/api-baseline-v1.1.41.json +1 -0
  22. package/spec/asset-baseline-v1.1.41.json +1 -0
  23. package/spec/feature-preservation.json +1 -0
  24. package/spec/library-scope.md +14 -4
  25. package/spec/native-source-manifest-v1.1.41.json +1 -0
  26. package/src/capabilities.mjs +4 -0
  27. package/src/convergence/AltiumCircuitJsonProjection.mjs +230 -0
  28. package/src/convergence/AltiumDocumentBuilder.mjs +167 -0
  29. package/src/convergence/AltiumExtensionResolver.mjs +98 -0
  30. package/src/convergence/AltiumProjectDocumentResolver.mjs +324 -0
  31. package/src/convergence/AltiumSchematicCoordinateProjection.mjs +130 -0
  32. package/src/convergence/AltiumWorkerClient.mjs +95 -0
  33. package/src/convergence/Parser.mjs +285 -0
  34. package/src/convergence/ParserInput.mjs +282 -0
  35. package/src/convergence/ProjectLoader.mjs +916 -0
  36. package/src/convergence/SchematicSvgRenderer.mjs +126 -0
  37. package/src/convergence/ToolkitCapabilities.mjs +49 -0
  38. package/src/core/circuit-json/CircuitJsonSchematicDocumentGraphicBuilder.mjs +785 -0
  39. package/src/core/circuit-json/CircuitJsonSchematicGraphicBuilder.mjs +909 -0
  40. package/src/core/circuit-json/CircuitJsonSchematicImageProjection.mjs +277 -0
  41. package/src/core/circuit-json/CircuitJsonSchematicStrokeStyle.mjs +47 -0
  42. package/src/extensions.mjs +11 -0
  43. package/src/index.mjs +20 -4
  44. package/src/interaction.mjs +7 -0
  45. package/src/legacy-netlist-query.mjs +11 -0
  46. package/src/legacy-parser.mjs +141 -0
  47. package/src/legacy-renderers.mjs +25 -0
  48. package/src/legacy-scene3d.mjs +10 -0
  49. package/src/manufacturing.mjs +4 -0
  50. package/src/parser.mjs +12 -138
  51. package/src/project.mjs +11 -0
  52. package/src/query.mjs +4 -0
  53. package/src/renderers.mjs +4 -22
  54. package/src/scene3d.mjs +5 -8
  55. package/src/simulation.mjs +4 -0
  56. package/src/styles/renderers.css +27 -0
  57. package/src/testing.mjs +8 -0
  58. package/src/workers/parser.worker.mjs +37 -0
@@ -0,0 +1,324 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { DocumentResult } from 'circuitjson-toolkit/parser'
5
+
6
+ import { SchematicProjectParameterResolver } from '../core/altium/SchematicProjectParameterResolver.mjs'
7
+
8
+ const SCHEMATIC_TITLE_BLOCK_FIELDS = [
9
+ 'title',
10
+ 'revision',
11
+ 'documentNumber',
12
+ 'sheetNumber',
13
+ 'sheetTotal',
14
+ 'date',
15
+ 'drawnBy'
16
+ ]
17
+
18
+ /**
19
+ * Resolves project-owned schematic strings inside canonical documents.
20
+ */
21
+ export class AltiumProjectDocumentResolver {
22
+ /**
23
+ * Returns canonical documents with referenced schematic strings resolved.
24
+ * @param {{ projectEntry?: { name: string, input: { data: string | ArrayBuffer | Uint8Array } } | null }} classified Classified project entries.
25
+ * @param {object[]} documents Parsed canonical documents.
26
+ * @param {string | string[]} extensionSelection Public extension selection.
27
+ * @returns {object[]} Original or resolved canonical documents.
28
+ */
29
+ static resolve(classified, documents, extensionSelection) {
30
+ const context = AltiumProjectDocumentResolver.#context(
31
+ classified,
32
+ documents
33
+ )
34
+ const exposesProjectContext =
35
+ AltiumProjectDocumentResolver.#exposesProjectContext(
36
+ extensionSelection
37
+ )
38
+
39
+ let changed = false
40
+ const resolved = documents.map((document) => {
41
+ const next = context
42
+ ? AltiumProjectDocumentResolver.#document(
43
+ document,
44
+ context,
45
+ exposesProjectContext
46
+ )
47
+ : AltiumProjectDocumentResolver.#withProjectContextExposure(
48
+ document,
49
+ exposesProjectContext
50
+ )
51
+ changed ||= next !== document
52
+ return next
53
+ })
54
+ return changed ? resolved : documents
55
+ }
56
+
57
+ /**
58
+ * Reads compact project facts from its canonical extension.
59
+ * @param {{ projectEntry?: { name: string, input: { data: string | ArrayBuffer | Uint8Array } } | null }} classified Classified project entries.
60
+ * @param {object[]} documents Parsed canonical documents.
61
+ * @returns {{ fileName: string, parameters: Record<string, any>, documents: string[] } | null} Project context.
62
+ */
63
+ static #context(classified, documents) {
64
+ const entry = classified?.projectEntry
65
+ if (!entry) return null
66
+ const projectDocument = documents.find(
67
+ (document) =>
68
+ document?.source?.fileType === 'prjpcb' &&
69
+ document.source.fileName === entry.name
70
+ )
71
+ const projectContext =
72
+ projectDocument?.extensions?.altium?.projectContext
73
+ if (!projectContext) return null
74
+ return {
75
+ fileName: entry.name,
76
+ parameters: projectContext.parameters || {},
77
+ documents: Array.isArray(projectContext.documents)
78
+ ? projectContext.documents
79
+ : []
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Resolves one schematic document without mutating its proven model.
85
+ * @param {object} document Canonical document.
86
+ * @param {{ fileName: string, parameters: Record<string, any>, documents: string[] }} context Project context.
87
+ * @param {boolean} exposesProjectContext Whether compact context remains public.
88
+ * @returns {object} Original or rebuilt canonical document.
89
+ */
90
+ static #document(document, context, exposesProjectContext) {
91
+ if (String(document?.source?.fileType || '') !== 'schdoc') {
92
+ return AltiumProjectDocumentResolver.#withProjectContextExposure(
93
+ document,
94
+ exposesProjectContext
95
+ )
96
+ }
97
+ if (
98
+ context.documents.length &&
99
+ !AltiumProjectDocumentResolver.#mentions(
100
+ context.documents,
101
+ document.source.fileName
102
+ )
103
+ ) {
104
+ return AltiumProjectDocumentResolver.#withProjectContextExposure(
105
+ document,
106
+ exposesProjectContext
107
+ )
108
+ }
109
+ const parameters = {
110
+ ...context.parameters,
111
+ ...AltiumProjectDocumentResolver.#currentValues(),
112
+ ProjectName: AltiumProjectDocumentResolver.#baseName(
113
+ context.fileName
114
+ ),
115
+ DataSourceFileName: AltiumProjectDocumentResolver.#baseName(
116
+ context.fileName
117
+ ),
118
+ DocumentName: AltiumProjectDocumentResolver.#baseName(
119
+ document.source.fileName
120
+ ),
121
+ DocumentFullPathAndName: document.source.fileName
122
+ }
123
+ let resolutionCount = 0
124
+ const model = document.model.map((element) => {
125
+ if (element?.type !== 'schematic_text') return element
126
+ const resolved = SchematicProjectParameterResolver.resolveText(
127
+ element.text,
128
+ parameters
129
+ )
130
+ if (!resolved || resolved.resolvedText === element.text) {
131
+ return element
132
+ }
133
+ resolutionCount += 1
134
+ return { ...element, text: resolved.resolvedText }
135
+ })
136
+ const hidesProjectContext =
137
+ !exposesProjectContext &&
138
+ Boolean(document?.extensions?.altium?.projectContext)
139
+ const native = hidesProjectContext
140
+ ? undefined
141
+ : document?.extensions?.altium?.native
142
+ const resolvedNative =
143
+ AltiumProjectDocumentResolver.#resolvedNativeDocument(
144
+ native,
145
+ parameters
146
+ )
147
+ const extensions = hidesProjectContext
148
+ ? {}
149
+ : resolvedNative === native
150
+ ? document.extensions
151
+ : {
152
+ altium: {
153
+ ...document.extensions.altium,
154
+ native: resolvedNative
155
+ }
156
+ }
157
+ if (
158
+ !resolutionCount &&
159
+ resolvedNative === native &&
160
+ extensions === document.extensions
161
+ ) {
162
+ return document
163
+ }
164
+ return AltiumProjectDocumentResolver.#rebuild(document, {
165
+ model,
166
+ extensions,
167
+ statistics:
168
+ resolutionCount || resolvedNative !== native
169
+ ? {
170
+ ...document.statistics,
171
+ resolvedProjectParameterCount: resolutionCount
172
+ }
173
+ : document.statistics
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Resolves retained native schematic text and title-block fields through the
179
+ * same project parameter set used by the canonical CircuitJSON model.
180
+ * @param {object | null | undefined} native Retained native renderer model.
181
+ * @param {Record<string, any>} parameters Resolved project parameters.
182
+ * @returns {object | null | undefined} Original or resolved native model.
183
+ */
184
+ static #resolvedNativeDocument(native, parameters) {
185
+ if (!native?.schematic) return native
186
+ const resolved = SchematicProjectParameterResolver.applyToDocumentModel(
187
+ native,
188
+ parameters,
189
+ { replaceText: true }
190
+ )
191
+ const sourceTexts = Array.isArray(native.schematic.texts)
192
+ ? native.schematic.texts
193
+ : []
194
+ const resolvedTexts = Array.isArray(resolved.schematic.texts)
195
+ ? resolved.schematic.texts
196
+ : []
197
+ const textChanged = sourceTexts.some(
198
+ (text, index) => text?.text !== resolvedTexts[index]?.text
199
+ )
200
+ const sourceTitleBlock = native.schematic.sheet?.titleBlock || {}
201
+ const resolvedTitleBlock = resolved.schematic.sheet?.titleBlock || {}
202
+ const titleBlockChanged = SCHEMATIC_TITLE_BLOCK_FIELDS.some(
203
+ (field) => sourceTitleBlock[field] !== resolvedTitleBlock[field]
204
+ )
205
+ if (!textChanged && !titleBlockChanged) return native
206
+ return { ...resolved, projectParameters: parameters }
207
+ }
208
+
209
+ /**
210
+ * Applies compact project-context exposure without rebuilding documents that
211
+ * already match the public selection.
212
+ * @param {object} document Canonical document.
213
+ * @param {boolean} exposesProjectContext Whether compact context remains public.
214
+ * @returns {object} Original or rebuilt project document.
215
+ */
216
+ static #withProjectContextExposure(document, exposesProjectContext) {
217
+ if (
218
+ exposesProjectContext ||
219
+ !document?.extensions?.altium?.projectContext
220
+ ) {
221
+ return document
222
+ }
223
+ return AltiumProjectDocumentResolver.#rebuild(document, {
224
+ extensions: {}
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Returns true when public selection includes compact project context.
230
+ * @param {string | string[]} selection Extension selection.
231
+ * @returns {boolean} Whether project facts remain public.
232
+ */
233
+ static #exposesProjectContext(selection) {
234
+ if (!Array.isArray(selection)) return selection !== 'none'
235
+ return (
236
+ selection.includes('altium.project-context') ||
237
+ selection.includes('altium.native-model')
238
+ )
239
+ }
240
+
241
+ /**
242
+ * Rebuilds one canonical document through the validating shared boundary.
243
+ * @param {object} document Existing canonical document.
244
+ * @param {{ model?: object[], extensions?: object, statistics?: object }} fields Replacements.
245
+ * @returns {object} Proven immutable document.
246
+ */
247
+ static #rebuild(document, fields) {
248
+ return DocumentResult.createValidated(
249
+ {
250
+ id: document.id,
251
+ model: fields.model || document.model,
252
+ source: document.source,
253
+ extensions:
254
+ fields.extensions === undefined
255
+ ? document.extensions
256
+ : fields.extensions,
257
+ assets: document.assets,
258
+ diagnostics: document.diagnostics,
259
+ statistics: fields.statistics || document.statistics
260
+ },
261
+ AltiumProjectDocumentResolver.#runtime(document)
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Preserves an explicit source reference when the parser retained one.
267
+ * @param {object} document Canonical document.
268
+ * @returns {{ sourceReference?: object }} Runtime-only fields.
269
+ */
270
+ static #runtime(document) {
271
+ const descriptor = Object.getOwnPropertyDescriptor(
272
+ document,
273
+ 'sourceReference'
274
+ )
275
+ return descriptor && Object.hasOwn(descriptor, 'value')
276
+ ? { sourceReference: descriptor.value }
277
+ : {}
278
+ }
279
+
280
+ /**
281
+ * Builds current-value special strings used by Altium templates.
282
+ * @returns {{ CurrentDate: string, CurrentTime: string }} Current values.
283
+ */
284
+ static #currentValues() {
285
+ const now = new Date()
286
+ return {
287
+ CurrentDate: now.toLocaleDateString('en-US'),
288
+ CurrentTime: now.toLocaleTimeString('en-US')
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Returns the final normalized path segment.
294
+ * @param {unknown} value Path value.
295
+ * @returns {string} Basename.
296
+ */
297
+ static #baseName(value) {
298
+ const path = String(value || '').replaceAll('\\', '/')
299
+ return path.split('/').pop() || path
300
+ }
301
+
302
+ /**
303
+ * Returns true when project paths own one schematic source path.
304
+ * @param {string[]} projectPaths Normalized project document paths.
305
+ * @param {string} fileName Canonical source path.
306
+ * @returns {boolean} Whether the project references the schematic.
307
+ */
308
+ static #mentions(projectPaths, fileName) {
309
+ const source = String(fileName || '').replaceAll('\\', '/')
310
+ const sourceBaseName = AltiumProjectDocumentResolver.#baseName(source)
311
+ return projectPaths.some((value) => {
312
+ const path = String(value || '').replaceAll('\\', '/')
313
+ return (
314
+ path === source ||
315
+ source.endsWith('/' + path) ||
316
+ path.endsWith('/' + source) ||
317
+ AltiumProjectDocumentResolver.#baseName(path) === sourceBaseName
318
+ )
319
+ })
320
+ }
321
+ }
322
+
323
+ Object.freeze(AltiumProjectDocumentResolver.prototype)
324
+ Object.freeze(AltiumProjectDocumentResolver)
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ const POINT_FIELDS = [
5
+ 'anchor_position',
6
+ 'center',
7
+ 'end',
8
+ 'mid',
9
+ 'position',
10
+ 'start'
11
+ ]
12
+ const POINT_LIST_FIELDS = ['junctions', 'outline', 'points']
13
+
14
+ /**
15
+ * Converts Altium's bottom-origin schematic geometry to canonical SVG space.
16
+ */
17
+ export class AltiumSchematicCoordinateProjection {
18
+ /**
19
+ * Reflects only schematic geometry through the authored sheet height.
20
+ * @param {object[]} model CircuitJSON model rows.
21
+ * @param {unknown} sheetHeight Native rendered sheet height.
22
+ * @returns {object[]} Projected model rows.
23
+ */
24
+ static project(model, sheetHeight) {
25
+ const height = Number(sheetHeight)
26
+ if (!Number.isFinite(height) || height <= 0) return [...model]
27
+ return model.map((element) =>
28
+ String(element?.type || '').startsWith('schematic_') &&
29
+ element.type !== 'schematic_sheet'
30
+ ? AltiumSchematicCoordinateProjection.#element(element, height)
31
+ : element
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Reflects every common point-bearing field on one schematic row.
37
+ * @param {Record<string, any>} element CircuitJSON element.
38
+ * @param {number} height Sheet height.
39
+ * @returns {Record<string, any>} Reflected element.
40
+ */
41
+ static #element(element, height) {
42
+ const projected = { ...element }
43
+ for (const field of POINT_FIELDS) {
44
+ if (element[field] !== undefined) {
45
+ projected[field] = AltiumSchematicCoordinateProjection.#point(
46
+ element[field],
47
+ height
48
+ )
49
+ }
50
+ }
51
+ for (const field of POINT_LIST_FIELDS) {
52
+ if (Array.isArray(element[field])) {
53
+ projected[field] = element[field].map((point) =>
54
+ AltiumSchematicCoordinateProjection.#point(point, height)
55
+ )
56
+ }
57
+ }
58
+ for (const field of ['y', 'y1', 'y2']) {
59
+ const value = Number(element[field])
60
+ if (Number.isFinite(value)) projected[field] = height - value
61
+ }
62
+ if (Array.isArray(element.edges)) {
63
+ projected.edges = element.edges.map((edge) => ({
64
+ ...edge,
65
+ ...(edge?.from
66
+ ? {
67
+ from: AltiumSchematicCoordinateProjection.#point(
68
+ edge.from,
69
+ height
70
+ )
71
+ }
72
+ : {}),
73
+ ...(edge?.to
74
+ ? {
75
+ to: AltiumSchematicCoordinateProjection.#point(
76
+ edge.to,
77
+ height
78
+ )
79
+ }
80
+ : {})
81
+ }))
82
+ }
83
+ if (element.type === 'schematic_arc') {
84
+ for (const field of ['start_angle_degrees', 'end_angle_degrees']) {
85
+ if (
86
+ Object.hasOwn(element, field) &&
87
+ Number.isFinite(Number(element[field]))
88
+ ) {
89
+ projected[field] =
90
+ AltiumSchematicCoordinateProjection.#angle(
91
+ element[field]
92
+ )
93
+ }
94
+ }
95
+ if (element.direction === 'clockwise') {
96
+ projected.direction = 'counterclockwise'
97
+ } else if (element.direction === 'counterclockwise') {
98
+ projected.direction = 'clockwise'
99
+ }
100
+ }
101
+ return projected
102
+ }
103
+
104
+ /**
105
+ * Reflects an angle while keeping canonical zero positive.
106
+ * @param {unknown} value Angle candidate.
107
+ * @returns {number} Reflected angle.
108
+ */
109
+ static #angle(value) {
110
+ const angle = Number(value || 0)
111
+ return angle ? -angle : 0
112
+ }
113
+
114
+ /**
115
+ * Reflects one point without dropping its relation metadata.
116
+ * @param {unknown} value Point candidate.
117
+ * @param {number} height Sheet height.
118
+ * @returns {unknown} Reflected point or the original value.
119
+ */
120
+ static #point(value, height) {
121
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
122
+ return value
123
+ }
124
+ const y = Number(value.y)
125
+ return Number.isFinite(y) ? { ...value, y: height - y } : value
126
+ }
127
+ }
128
+
129
+ Object.freeze(AltiumSchematicCoordinateProjection.prototype)
130
+ Object.freeze(AltiumSchematicCoordinateProjection)
@@ -0,0 +1,95 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { ParserWorkerClient } from 'circuitjson-toolkit/parser'
5
+
6
+ let client = null
7
+
8
+ /**
9
+ * Owns the source-format worker client while reusing the shared protocol.
10
+ */
11
+ export class AltiumWorkerClient {
12
+ /** @returns {boolean} Whether a browser-compatible Worker is available. */
13
+ static isAvailable() {
14
+ try {
15
+ return typeof globalThis.Worker === 'function'
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Parses one request through the shared worker protocol.
23
+ * @param {Record<string, any>} input Parser input.
24
+ * @param {Record<string, any>} options Common parser options.
25
+ * @returns {Promise<Record<string, any>>} Canonical document.
26
+ */
27
+ static async parse(input, options) {
28
+ return await AltiumWorkerClient.#client().parse(input, options)
29
+ }
30
+
31
+ /**
32
+ * Parses through the worker while distinguishing local construction
33
+ * unavailability from parser, protocol, and runtime failures.
34
+ * @param {Record<string, any>} input Parser input.
35
+ * @param {Record<string, any>} options Common parser options.
36
+ * @returns {Promise<{ ok: true, value: object } | { ok: false, error: unknown, unavailable: boolean }>} Attempt result.
37
+ */
38
+ static async parseAttempt(input, options) {
39
+ return await AltiumWorkerClient.#client().parseAttempt(input, options)
40
+ }
41
+
42
+ /**
43
+ * Loads one project through the shared worker protocol.
44
+ * @param {Record<string, any>[]} entries Project entries.
45
+ * @param {Record<string, any>} options Common loader options.
46
+ * @returns {Promise<Record<string, any>>} Canonical project.
47
+ */
48
+ static async loadProject(entries, options) {
49
+ return await AltiumWorkerClient.#client().loadProject(entries, options)
50
+ }
51
+
52
+ /**
53
+ * Loads through the worker while preserving operation failures.
54
+ * @param {Record<string, any>[]} entries Project entries.
55
+ * @param {Record<string, any>} options Common loader options.
56
+ * @returns {Promise<{ ok: true, value: object } | { ok: false, error: unknown, unavailable: boolean }>} Attempt result.
57
+ */
58
+ static async loadProjectAttempt(entries, options) {
59
+ return await AltiumWorkerClient.#client().loadProjectAttempt(
60
+ entries,
61
+ options
62
+ )
63
+ }
64
+
65
+ /** Disposes the current source worker client. */
66
+ static dispose() {
67
+ client?.dispose()
68
+ client = null
69
+ }
70
+
71
+ /**
72
+ * Returns the lazy source worker client.
73
+ * @returns {ParserWorkerClient} Shared-protocol client.
74
+ */
75
+ static #client() {
76
+ if (!client) {
77
+ client = new ParserWorkerClient({
78
+ createWorker: () => {
79
+ const WorkerConstructor = globalThis.Worker
80
+ return Reflect.construct(WorkerConstructor, [
81
+ new URL(
82
+ '../workers/parser.worker.mjs',
83
+ import.meta.url
84
+ ),
85
+ { type: 'module' }
86
+ ])
87
+ }
88
+ })
89
+ }
90
+ return client
91
+ }
92
+ }
93
+
94
+ Object.freeze(AltiumWorkerClient.prototype)
95
+ Object.freeze(AltiumWorkerClient)