altium-toolkit 1.1.2 → 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.
- package/docs/api.md +37 -0
- package/docs/model-format.md +18 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
- package/docs/testing.md +5 -0
- package/package.json +1 -1
- package/spec/library-scope.md +5 -0
- package/src/core/altium/AltiumLayoutParser.mjs +275 -13
- package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
- package/src/core/altium/AltiumParser.mjs +245 -10
- package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
- package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
- package/src/core/altium/AsciiRecordParser.mjs +43 -11
- package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
- package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
- package/src/core/altium/PrintableTextDecoder.mjs +133 -13
- package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
- package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
- package/src/core/altium/SchematicImageParser.mjs +291 -6
- package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
- package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
- package/src/core/altium/SchematicPinParser.mjs +262 -24
- package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
- package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
- package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
- package/src/core/altium/SchematicTextParser.mjs +125 -11
- package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
- package/src/core/altium/SourceBundleExporter.mjs +156 -0
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
- package/src/core/altium/SourceComponentClient.mjs +239 -0
- package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
- package/src/parser.mjs +8 -0
- package/src/styles/altium-renderers.css +6 -6
- package/src/ui/PcbArcUtils.mjs +19 -2
- package/src/ui/PcbScene3dBuilder.mjs +202 -20
- package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
- package/src/ui/SchematicColorResolver.mjs +263 -0
- package/src/ui/SchematicContentLayout.mjs +58 -1
- package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
- package/src/ui/SchematicImageRenderer.mjs +125 -10
- package/src/ui/SchematicJunctionRenderer.mjs +1 -1
- package/src/ui/SchematicLineColorResolver.mjs +88 -0
- package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
- package/src/ui/SchematicNoteRenderer.mjs +87 -7
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
- package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
- package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
- package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
- package/src/ui/SchematicShapeRenderer.mjs +109 -24
- package/src/ui/SchematicSvgRenderer.mjs +1210 -71
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
* Exports normalized source component bundles as deterministic file entries.
|
|
9
|
+
*/
|
|
10
|
+
export class SourceBundleExporter {
|
|
11
|
+
/**
|
|
12
|
+
* Exports one source bundle.
|
|
13
|
+
* @param {object} componentBundle Component bundle or raw response.
|
|
14
|
+
* @param {{ includeModels?: boolean }} [options] Export options.
|
|
15
|
+
* @returns {{ manifest: object, entries: { path: string, bytes: Uint8Array, contentType: string }[] }}
|
|
16
|
+
*/
|
|
17
|
+
static export(componentBundle, options = {}) {
|
|
18
|
+
const bundle =
|
|
19
|
+
SourceComponentBundleNormalizer.normalize(componentBundle)
|
|
20
|
+
const includeModels = options.includeModels !== false
|
|
21
|
+
const manifest = SourceBundleExporter.#buildManifest(
|
|
22
|
+
bundle,
|
|
23
|
+
includeModels
|
|
24
|
+
)
|
|
25
|
+
const entries = [
|
|
26
|
+
SourceBundleExporter.#jsonEntry('manifest.json', manifest),
|
|
27
|
+
SourceBundleExporter.#jsonEntry(
|
|
28
|
+
'source/source.json',
|
|
29
|
+
bundle.sourceJson
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
if (includeModels) {
|
|
34
|
+
entries.push(
|
|
35
|
+
...bundle.models.map((model) => ({
|
|
36
|
+
path:
|
|
37
|
+
'models/' +
|
|
38
|
+
SourceBundleExporter.#safeFileName(model.name),
|
|
39
|
+
bytes: model.bytes,
|
|
40
|
+
contentType: SourceBundleExporter.#modelContentType(model)
|
|
41
|
+
}))
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
manifest,
|
|
47
|
+
entries: entries.sort((left, right) =>
|
|
48
|
+
left.path.localeCompare(right.path)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a deterministic manifest.
|
|
55
|
+
* @param {object} bundle Normalized bundle.
|
|
56
|
+
* @param {boolean} includeModels Whether model assets are included.
|
|
57
|
+
* @returns {object}
|
|
58
|
+
*/
|
|
59
|
+
static #buildManifest(bundle, includeModels) {
|
|
60
|
+
return {
|
|
61
|
+
schema: 'ecad-source-bundle-v1',
|
|
62
|
+
component: {
|
|
63
|
+
id: bundle.id,
|
|
64
|
+
name: bundle.name,
|
|
65
|
+
symbolName: bundle.symbol.name,
|
|
66
|
+
footprintName: bundle.footprint.name
|
|
67
|
+
},
|
|
68
|
+
assets: includeModels
|
|
69
|
+
? bundle.models.map((model) => ({
|
|
70
|
+
name: model.name,
|
|
71
|
+
format: model.format,
|
|
72
|
+
path:
|
|
73
|
+
'models/' +
|
|
74
|
+
SourceBundleExporter.#safeFileName(model.name),
|
|
75
|
+
byteLength: model.bytes.byteLength
|
|
76
|
+
}))
|
|
77
|
+
: []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates one JSON entry.
|
|
83
|
+
* @param {string} path Entry path.
|
|
84
|
+
* @param {object} value JSON value.
|
|
85
|
+
* @returns {{ path: string, bytes: Uint8Array, contentType: string }}
|
|
86
|
+
*/
|
|
87
|
+
static #jsonEntry(path, value) {
|
|
88
|
+
return {
|
|
89
|
+
path,
|
|
90
|
+
bytes: new TextEncoder().encode(
|
|
91
|
+
SourceBundleExporter.#stableStringify(value) + '\n'
|
|
92
|
+
),
|
|
93
|
+
contentType: 'application/json'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns a stable JSON string with sorted object keys.
|
|
99
|
+
* @param {any} value JSON-compatible value.
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
static #stableStringify(value) {
|
|
103
|
+
return JSON.stringify(SourceBundleExporter.#sortJson(value), null, 2)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recursively sorts JSON object keys.
|
|
108
|
+
* @param {any} value JSON-compatible value.
|
|
109
|
+
* @returns {any}
|
|
110
|
+
*/
|
|
111
|
+
static #sortJson(value) {
|
|
112
|
+
if (Array.isArray(value)) {
|
|
113
|
+
return value.map((entry) => SourceBundleExporter.#sortJson(entry))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
value &&
|
|
118
|
+
typeof value === 'object' &&
|
|
119
|
+
!(value instanceof Uint8Array)
|
|
120
|
+
) {
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.keys(value)
|
|
123
|
+
.sort()
|
|
124
|
+
.map((key) => [
|
|
125
|
+
key,
|
|
126
|
+
SourceBundleExporter.#sortJson(value[key])
|
|
127
|
+
])
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns a safe bundle file name.
|
|
136
|
+
* @param {string} name Raw file name.
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
static #safeFileName(name) {
|
|
140
|
+
return String(name || 'model.step').replace(
|
|
141
|
+
/[\\/:\u0000-\u001f]/gu,
|
|
142
|
+
'_'
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolves a model content type.
|
|
148
|
+
* @param {object} model Model descriptor.
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
static #modelContentType(model) {
|
|
152
|
+
if (model.format === 'obj') return 'model/obj'
|
|
153
|
+
if (model.format === 'mtl') return 'text/plain'
|
|
154
|
+
return 'model/step'
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -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
|
+
}
|