altium-toolkit 1.1.3 → 1.1.22

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 (39) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  8. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  9. package/src/core/altium/AltiumParser.mjs +5 -2
  10. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  11. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  12. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  13. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  14. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  15. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  16. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  17. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  18. package/src/core/altium/SchematicPinParser.mjs +87 -20
  19. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  20. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  21. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  22. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  23. package/src/core/altium/SourceComponentClient.mjs +239 -0
  24. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  25. package/src/parser.mjs +8 -0
  26. package/src/styles/altium-renderers.css +6 -6
  27. package/src/ui/PcbArcUtils.mjs +19 -2
  28. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  29. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  30. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  31. package/src/ui/SchematicColorResolver.mjs +185 -0
  32. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  33. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  34. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  35. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  36. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  37. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  38. package/src/ui/SchematicShapeRenderer.mjs +37 -11
  39. package/src/ui/SchematicSvgRenderer.mjs +944 -51
@@ -0,0 +1,295 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes component-provider responses into one exporter-ready bundle.
7
+ */
8
+ export class SourceComponentBundleNormalizer {
9
+ /**
10
+ * Normalizes a raw provider response.
11
+ * @param {object} rawComponent Raw component response.
12
+ * @returns {{ id: string, name: string, metadata: object, symbol: object, footprint: object, models: object[], sourceJson: object, diagnostics: object[] }}
13
+ */
14
+ static normalize(rawComponent = {}) {
15
+ const source = SourceComponentBundleNormalizer.#unwrap(rawComponent)
16
+ const metadata = SourceComponentBundleNormalizer.#normalizeMetadata(
17
+ rawComponent,
18
+ source
19
+ )
20
+ const symbol = SourceComponentBundleNormalizer.#normalizeSymbol(source)
21
+ const footprint =
22
+ SourceComponentBundleNormalizer.#normalizeFootprint(source)
23
+ const models = SourceComponentBundleNormalizer.#normalizeModels(source)
24
+ const bundle = {
25
+ id: SourceComponentBundleNormalizer.#firstString(
26
+ source.id,
27
+ source.uuid,
28
+ source.componentId,
29
+ rawComponent.id,
30
+ rawComponent.uuid,
31
+ metadata.partNumber,
32
+ metadata.name,
33
+ 'component'
34
+ ),
35
+ name: SourceComponentBundleNormalizer.#firstString(
36
+ source.name,
37
+ source.title,
38
+ rawComponent.name,
39
+ rawComponent.title,
40
+ metadata.name,
41
+ metadata.partNumber,
42
+ 'Component'
43
+ ),
44
+ metadata,
45
+ symbol,
46
+ footprint,
47
+ models,
48
+ sourceJson: rawComponent,
49
+ diagnostics: []
50
+ }
51
+
52
+ bundle.diagnostics =
53
+ SourceComponentBundleNormalizer.#buildDiagnostics(bundle)
54
+
55
+ return bundle
56
+ }
57
+
58
+ /**
59
+ * Unwraps common response envelopes.
60
+ * @param {object} rawComponent Raw component response.
61
+ * @returns {object}
62
+ */
63
+ static #unwrap(rawComponent) {
64
+ return (
65
+ rawComponent?.data?.component ||
66
+ rawComponent?.data?.result ||
67
+ rawComponent?.data ||
68
+ rawComponent?.result ||
69
+ rawComponent?.component ||
70
+ rawComponent ||
71
+ {}
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Normalizes component metadata.
77
+ * @param {object} rawComponent Raw component response.
78
+ * @param {object} source Unwrapped response.
79
+ * @returns {object}
80
+ */
81
+ static #normalizeMetadata(rawComponent, source) {
82
+ return {
83
+ ...(rawComponent?.metadata || {}),
84
+ ...(source?.metadata || {}),
85
+ ...(source?.attributes || {})
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Normalizes the schematic symbol portion of a bundle.
91
+ * @param {object} source Unwrapped response.
92
+ * @returns {{ name: string, pins: object[], primitives: object[], raw: object }}
93
+ */
94
+ static #normalizeSymbol(source) {
95
+ const symbol = source?.symbol || source?.schematic || {}
96
+
97
+ return {
98
+ name: SourceComponentBundleNormalizer.#firstString(
99
+ symbol.name,
100
+ symbol.title,
101
+ source.symbolName,
102
+ source.name,
103
+ 'Component'
104
+ ),
105
+ pins: SourceComponentBundleNormalizer.#array(
106
+ symbol.pins || symbol.pinList
107
+ ),
108
+ primitives: SourceComponentBundleNormalizer.#array(
109
+ symbol.primitives || symbol.shapes || symbol.graphics
110
+ ),
111
+ raw: symbol
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Normalizes the PCB footprint portion of a bundle.
117
+ * @param {object} source Unwrapped response.
118
+ * @returns {{ name: string, pads: object[], tracks: object[], arcs: object[], fills: object[], texts: object[], primitives: object[], raw: object }}
119
+ */
120
+ static #normalizeFootprint(source) {
121
+ const footprint = source?.footprint || source?.package || {}
122
+
123
+ return {
124
+ name: SourceComponentBundleNormalizer.#firstString(
125
+ footprint.name,
126
+ footprint.title,
127
+ source.footprintName,
128
+ source.packageName,
129
+ source.name,
130
+ 'Component'
131
+ ),
132
+ pads: SourceComponentBundleNormalizer.#array(footprint.pads),
133
+ tracks: SourceComponentBundleNormalizer.#array(footprint.tracks),
134
+ arcs: SourceComponentBundleNormalizer.#array(footprint.arcs),
135
+ fills: SourceComponentBundleNormalizer.#array(footprint.fills),
136
+ texts: SourceComponentBundleNormalizer.#array(footprint.texts),
137
+ primitives: SourceComponentBundleNormalizer.#array(
138
+ footprint.primitives || footprint.shapes
139
+ ),
140
+ raw: footprint
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Normalizes model asset descriptors.
146
+ * @param {object} source Unwrapped response.
147
+ * @returns {object[]}
148
+ */
149
+ static #normalizeModels(source) {
150
+ const models = SourceComponentBundleNormalizer.#array(
151
+ source?.models || source?.modelAssets || source?.assets
152
+ )
153
+
154
+ return models
155
+ .map((model, index) =>
156
+ SourceComponentBundleNormalizer.#normalizeModel(model, index)
157
+ )
158
+ .filter(Boolean)
159
+ }
160
+
161
+ /**
162
+ * Normalizes one model asset descriptor.
163
+ * @param {object} model Raw model asset.
164
+ * @param {number} index Model index.
165
+ * @returns {object | null}
166
+ */
167
+ static #normalizeModel(model, index) {
168
+ if (!model || typeof model !== 'object') {
169
+ return null
170
+ }
171
+
172
+ const name = SourceComponentBundleNormalizer.#firstString(
173
+ model.name,
174
+ model.fileName,
175
+ model.path,
176
+ 'model-' + index + '.step'
177
+ )
178
+ const bytes =
179
+ SourceComponentBundleNormalizer.#normalizeModelBytes(model)
180
+
181
+ return {
182
+ id: SourceComponentBundleNormalizer.#firstString(
183
+ model.id,
184
+ model.uuid,
185
+ 'model-' + index
186
+ ),
187
+ name,
188
+ format: SourceComponentBundleNormalizer.#normalizeFormat(
189
+ model.format,
190
+ name
191
+ ),
192
+ bytes,
193
+ text:
194
+ typeof model.text === 'string'
195
+ ? model.text
196
+ : new TextDecoder().decode(bytes),
197
+ sourceUrl: SourceComponentBundleNormalizer.#firstString(
198
+ model.url,
199
+ model.downloadUrl,
200
+ model.sourceUrl
201
+ ),
202
+ raw: model
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Normalizes one model payload into bytes.
208
+ * @param {object} model Raw model asset.
209
+ * @returns {Uint8Array}
210
+ */
211
+ static #normalizeModelBytes(model) {
212
+ if (model.bytes instanceof Uint8Array) {
213
+ return new Uint8Array(model.bytes)
214
+ }
215
+
216
+ if (model.arrayBuffer instanceof ArrayBuffer) {
217
+ return new Uint8Array(model.arrayBuffer)
218
+ }
219
+
220
+ if (typeof model.text === 'string') {
221
+ return new TextEncoder().encode(model.text)
222
+ }
223
+
224
+ if (typeof model.content === 'string') {
225
+ return new TextEncoder().encode(model.content)
226
+ }
227
+
228
+ return new Uint8Array(0)
229
+ }
230
+
231
+ /**
232
+ * Normalizes one model format id.
233
+ * @param {any} format Explicit format.
234
+ * @param {string} name File name.
235
+ * @returns {string}
236
+ */
237
+ static #normalizeFormat(format, name) {
238
+ const explicit = String(format || '').toLowerCase()
239
+ if (explicit) {
240
+ return explicit.replace(/^\./u, '')
241
+ }
242
+
243
+ const extension = String(name || '')
244
+ .split('.')
245
+ .at(-1)
246
+ return extension ? extension.toLowerCase() : 'step'
247
+ }
248
+
249
+ /**
250
+ * Builds bundle diagnostics.
251
+ * @param {object} bundle Normalized bundle.
252
+ * @returns {object[]}
253
+ */
254
+ static #buildDiagnostics(bundle) {
255
+ const diagnostics = []
256
+
257
+ if (!bundle.symbol.raw || !Object.keys(bundle.symbol.raw).length) {
258
+ diagnostics.push({
259
+ severity: 'warning',
260
+ message: 'No schematic symbol data was present.'
261
+ })
262
+ }
263
+
264
+ if (
265
+ !bundle.footprint.raw ||
266
+ !Object.keys(bundle.footprint.raw).length
267
+ ) {
268
+ diagnostics.push({
269
+ severity: 'warning',
270
+ message: 'No PCB footprint data was present.'
271
+ })
272
+ }
273
+
274
+ return diagnostics
275
+ }
276
+
277
+ /**
278
+ * Returns an array for a possible array value.
279
+ * @param {any} value Possible array.
280
+ * @returns {object[]}
281
+ */
282
+ static #array(value) {
283
+ return Array.isArray(value) ? value : []
284
+ }
285
+
286
+ /**
287
+ * Returns the first non-empty string.
288
+ * @param {...any} values Candidate values.
289
+ * @returns {string}
290
+ */
291
+ static #firstString(...values) {
292
+ const value = values.find((candidate) => String(candidate || '').trim())
293
+ return String(value || '')
294
+ }
295
+ }
@@ -0,0 +1,239 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SourceComponentBundleNormalizer } from './SourceComponentBundleNormalizer.mjs'
6
+
7
+ /**
8
+ * Fetches source component records through an injected HTTP fetcher.
9
+ */
10
+ export class SourceComponentClient {
11
+ #baseUrl
12
+
13
+ #componentPath
14
+
15
+ #fetcher
16
+
17
+ #headers
18
+
19
+ #modelPath
20
+
21
+ #retryCount
22
+
23
+ #retryDelayMs
24
+
25
+ #searchPath
26
+
27
+ /**
28
+ * @param {{ fetcher?: Function, baseUrl?: string, searchPath?: string, componentPath?: string, modelPath?: string, retryCount?: number, retryDelayMs?: number, headers?: Record<string, string> }} [options] Client options.
29
+ */
30
+ constructor(options = {}) {
31
+ this.#fetcher = options.fetcher || null
32
+ this.#baseUrl = String(options.baseUrl || '').replace(/\/$/u, '')
33
+ this.#searchPath = String(
34
+ options.searchPath || '/api/components/search'
35
+ )
36
+ this.#componentPath = String(
37
+ options.componentPath || '/api/components/{id}'
38
+ )
39
+ this.#modelPath = String(options.modelPath || '/api/models/{name}')
40
+ this.#retryCount = Math.max(0, Number(options.retryCount || 0))
41
+ this.#retryDelayMs = Math.max(0, Number(options.retryDelayMs || 0))
42
+ this.#headers = { ...(options.headers || {}) }
43
+ }
44
+
45
+ /**
46
+ * Searches provider components.
47
+ * @param {string} query Search query.
48
+ * @param {{ limit?: number }} [options] Search options.
49
+ * @returns {Promise<{ id: string, name: string, raw: object }[]>}
50
+ */
51
+ async searchComponents(query, options = {}) {
52
+ this.#assertFetcher()
53
+ const url = new URL(this.#baseUrl + this.#searchPath)
54
+ url.searchParams.set('q', String(query || ''))
55
+ if (options.limit) {
56
+ url.searchParams.set('limit', String(options.limit))
57
+ }
58
+ const json = await this.#requestJson(url)
59
+ const rows = SourceComponentClient.#extractRows(json)
60
+
61
+ return rows.map((row) => ({
62
+ id: String(row.id || row.uuid || row.componentId || ''),
63
+ name: String(row.name || row.title || row.id || ''),
64
+ raw: row
65
+ }))
66
+ }
67
+
68
+ /**
69
+ * Fetches and normalizes one source component bundle.
70
+ * @param {string} id Component identifier.
71
+ * @returns {Promise<object>}
72
+ */
73
+ async fetchComponentBundle(id) {
74
+ const json = await this.#requestJson(
75
+ this.#resolveTemplateUrl(this.#componentPath, { id })
76
+ )
77
+
78
+ return SourceComponentBundleNormalizer.normalize(json)
79
+ }
80
+
81
+ /**
82
+ * Fetches a model text asset.
83
+ * @param {string} urlOrName URL or model name.
84
+ * @returns {Promise<string>}
85
+ */
86
+ async fetchTextAsset(urlOrName) {
87
+ const response = await this.#fetchWithRetry(
88
+ this.#resolveAssetUrl(urlOrName)
89
+ )
90
+ if (typeof response.text === 'function') {
91
+ return response.text()
92
+ }
93
+
94
+ const bytes = await SourceComponentClient.#responseBytes(response)
95
+ return new TextDecoder().decode(bytes)
96
+ }
97
+
98
+ /**
99
+ * Fetches a model binary asset.
100
+ * @param {string} urlOrName URL or model name.
101
+ * @returns {Promise<Uint8Array>}
102
+ */
103
+ async fetchBinaryAsset(urlOrName) {
104
+ return SourceComponentClient.#responseBytes(
105
+ await this.#fetchWithRetry(this.#resolveAssetUrl(urlOrName))
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Requests JSON.
111
+ * @param {URL | string} url Request URL.
112
+ * @returns {Promise<any>}
113
+ */
114
+ async #requestJson(url) {
115
+ const response = await this.#fetchWithRetry(url)
116
+ if (typeof response.json === 'function') {
117
+ return response.json()
118
+ }
119
+
120
+ return JSON.parse(await response.text())
121
+ }
122
+
123
+ /**
124
+ * Fetches with retry.
125
+ * @param {URL | string} url Request URL.
126
+ * @returns {Promise<object>}
127
+ */
128
+ async #fetchWithRetry(url) {
129
+ this.#assertFetcher()
130
+
131
+ let lastError = null
132
+ for (let attempt = 0; attempt <= this.#retryCount; attempt += 1) {
133
+ try {
134
+ const response = await this.#fetcher(String(url), {
135
+ headers: this.#headers
136
+ })
137
+ if (response?.ok !== false) {
138
+ return response
139
+ }
140
+ lastError = new Error(
141
+ 'Source component request failed with status ' +
142
+ String(response.status || 0)
143
+ )
144
+ } catch (error) {
145
+ lastError = error
146
+ }
147
+
148
+ if (attempt < this.#retryCount && this.#retryDelayMs) {
149
+ await SourceComponentClient.#delay(this.#retryDelayMs)
150
+ }
151
+ }
152
+
153
+ throw lastError
154
+ }
155
+
156
+ /**
157
+ * Asserts that a fetcher was injected.
158
+ * @returns {void}
159
+ */
160
+ #assertFetcher() {
161
+ if (typeof this.#fetcher !== 'function') {
162
+ throw new Error('SourceComponentClient fetcher is required.')
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Resolves a URL template.
168
+ * @param {string} template URL template.
169
+ * @param {Record<string, string>} values Template values.
170
+ * @returns {URL}
171
+ */
172
+ #resolveTemplateUrl(template, values) {
173
+ const path = Object.entries(values).reduce(
174
+ (nextPath, [key, value]) =>
175
+ nextPath.replaceAll(
176
+ '{' + key + '}',
177
+ encodeURIComponent(String(value || ''))
178
+ ),
179
+ template
180
+ )
181
+
182
+ return new URL(this.#baseUrl + path)
183
+ }
184
+
185
+ /**
186
+ * Resolves a model asset URL or name.
187
+ * @param {string} urlOrName URL or model name.
188
+ * @returns {URL}
189
+ */
190
+ #resolveAssetUrl(urlOrName) {
191
+ const value = String(urlOrName || '')
192
+ if (/^https?:\/\//iu.test(value)) {
193
+ return new URL(value)
194
+ }
195
+
196
+ return this.#resolveTemplateUrl(this.#modelPath, { name: value })
197
+ }
198
+
199
+ /**
200
+ * Extracts search rows from common response shapes.
201
+ * @param {any} json Response JSON.
202
+ * @returns {object[]}
203
+ */
204
+ static #extractRows(json) {
205
+ const rows =
206
+ json?.results ||
207
+ json?.items ||
208
+ json?.data?.results ||
209
+ json?.data?.items ||
210
+ json?.data ||
211
+ json
212
+
213
+ return Array.isArray(rows) ? rows : []
214
+ }
215
+
216
+ /**
217
+ * Reads response bytes.
218
+ * @param {object} response Response-like object.
219
+ * @returns {Promise<Uint8Array>}
220
+ */
221
+ static async #responseBytes(response) {
222
+ if (typeof response.arrayBuffer === 'function') {
223
+ return new Uint8Array(await response.arrayBuffer())
224
+ }
225
+
226
+ return new TextEncoder().encode(await response.text())
227
+ }
228
+
229
+ /**
230
+ * Delays retry execution.
231
+ * @param {number} milliseconds Delay in milliseconds.
232
+ * @returns {Promise<void>}
233
+ */
234
+ static #delay(milliseconds) {
235
+ return new Promise((resolve) => {
236
+ setTimeout(resolve, milliseconds)
237
+ })
238
+ }
239
+ }