altium-toolkit 1.1.33 → 1.1.36
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/AGENTS.md +2 -2
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
- package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/PrintableTextDecoder.mjs +20 -1
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
- package/src/core/altium/SchematicTextParser.mjs +18 -253
- package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
- package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/parser.mjs +1 -0
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +225 -17
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +470 -20
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +31 -4
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +471 -35
- package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +897 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +555 -0
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +897 -0
- package/src/ui/SchematicColorResolver.mjs +44 -0
- package/src/ui/SchematicLineColorResolver.mjs +1 -1
- package/src/ui/SchematicShapeRenderer.mjs +13 -13
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helpers for recovering Altium schematic title-block footer metadata.
|
|
9
|
+
*/
|
|
10
|
+
export class SchematicTitleBlockParser {
|
|
11
|
+
/**
|
|
12
|
+
* Extracts footer metadata used for the synthesized title block.
|
|
13
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
14
|
+
* @param {Record<string, string>} metadata
|
|
15
|
+
* @param {number} sheetWidth
|
|
16
|
+
* @param {Record<string, { size: number, family: string, bold: boolean, italic?: boolean, rotation: number }>} fonts
|
|
17
|
+
* @returns {{ title: string, revision: string, documentNumber: string, sheetNumber: string, sheetTotal: string, date: string, drawnBy: string, footerHints: Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>> }}
|
|
18
|
+
*/
|
|
19
|
+
static extract(records, metadata, sheetWidth, fonts) {
|
|
20
|
+
const footerTexts = records
|
|
21
|
+
.filter((record) =>
|
|
22
|
+
SchematicTitleBlockParser.isFooterRecord(
|
|
23
|
+
record.fields,
|
|
24
|
+
sheetWidth
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
.map((record) =>
|
|
28
|
+
SchematicTitleBlockParser.#normalizeFooterRecord(
|
|
29
|
+
record.fields,
|
|
30
|
+
fonts
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.sort((left, right) => right.y - left.y || left.x - right.x)
|
|
35
|
+
const footerHints =
|
|
36
|
+
SchematicTitleBlockParser.#collectFooterHints(footerTexts)
|
|
37
|
+
const numericFooterTexts = footerTexts.filter((record) =>
|
|
38
|
+
/^\d+$/.test(record.text)
|
|
39
|
+
)
|
|
40
|
+
const footerDrawnBy = SchematicTitleBlockParser.#extractDrawnBy(
|
|
41
|
+
footerTexts,
|
|
42
|
+
metadata
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
title:
|
|
47
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
48
|
+
footerHints.title?.text,
|
|
49
|
+
metadata
|
|
50
|
+
) ||
|
|
51
|
+
SchematicTitleBlockParser.#cleanMetadataValue(metadata.title),
|
|
52
|
+
revision:
|
|
53
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
54
|
+
footerHints.revision?.text,
|
|
55
|
+
metadata
|
|
56
|
+
) ||
|
|
57
|
+
SchematicTitleBlockParser.#cleanMetadataValue(
|
|
58
|
+
metadata.revision
|
|
59
|
+
),
|
|
60
|
+
documentNumber: SchematicTitleBlockParser.#cleanMetadataValue(
|
|
61
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
62
|
+
footerHints.documentNumber?.text,
|
|
63
|
+
metadata
|
|
64
|
+
) || metadata.documentnumber
|
|
65
|
+
),
|
|
66
|
+
sheetNumber:
|
|
67
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
68
|
+
footerHints.sheetNumber?.text,
|
|
69
|
+
metadata
|
|
70
|
+
) ||
|
|
71
|
+
numericFooterTexts[0]?.text ||
|
|
72
|
+
SchematicTitleBlockParser.#cleanMetadataValue(
|
|
73
|
+
metadata.sheetnumber
|
|
74
|
+
),
|
|
75
|
+
sheetTotal:
|
|
76
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
77
|
+
footerHints.sheetTotal?.text,
|
|
78
|
+
metadata
|
|
79
|
+
) ||
|
|
80
|
+
numericFooterTexts[1]?.text ||
|
|
81
|
+
SchematicTitleBlockParser.#cleanMetadataValue(
|
|
82
|
+
metadata.sheettotal
|
|
83
|
+
),
|
|
84
|
+
date: SchematicTitleBlockParser.#cleanMetadataValue(
|
|
85
|
+
metadata.currentdate || metadata.date
|
|
86
|
+
),
|
|
87
|
+
drawnBy:
|
|
88
|
+
SchematicTitleBlockParser.#cleanMetadataValue(
|
|
89
|
+
metadata.drawnby
|
|
90
|
+
) || footerDrawnBy,
|
|
91
|
+
footerHints: SchematicTitleBlockParser.#stripHintText(footerHints)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns true when the text primitive belongs to the page footer template.
|
|
97
|
+
* @param {Record<string, string | string[]>} fields
|
|
98
|
+
* @param {number} sheetWidth
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
static isFooterRecord(fields, sheetWidth) {
|
|
102
|
+
const recordType = ParserUtils.getField(fields, 'RECORD')
|
|
103
|
+
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
104
|
+
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
recordType === '4' &&
|
|
108
|
+
x !== null &&
|
|
109
|
+
y !== null &&
|
|
110
|
+
x >= sheetWidth * 0.55 &&
|
|
111
|
+
y <= 100
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Normalizes one visible footer text record into a title-block layout hint.
|
|
117
|
+
* @param {Record<string, string | string[]>} fields
|
|
118
|
+
* @param {Record<string, { size: number, family: string, bold: boolean, italic?: boolean, rotation: number }} fonts
|
|
119
|
+
* @returns {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string } | null}
|
|
120
|
+
*/
|
|
121
|
+
static #normalizeFooterRecord(fields, fonts) {
|
|
122
|
+
const text = ParserUtils.getDisplayText(fields)
|
|
123
|
+
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
124
|
+
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
125
|
+
|
|
126
|
+
if (!text || x === null || y === null) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const font =
|
|
131
|
+
(fonts || {})[ParserUtils.getField(fields, 'FontID')] ||
|
|
132
|
+
SchematicTitleBlockParser.#defaultFont()
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
text,
|
|
136
|
+
x,
|
|
137
|
+
y,
|
|
138
|
+
color: ParserUtils.toColor(fields.Color, '#2c3134'),
|
|
139
|
+
fontSize: font.size,
|
|
140
|
+
fontFamily: font.family,
|
|
141
|
+
fontWeight: font.bold ? 700 : 400,
|
|
142
|
+
...(font.italic ? { fontStyle: 'italic' } : {})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Maps visible footer rows onto title-block fields.
|
|
148
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
149
|
+
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>}
|
|
150
|
+
*/
|
|
151
|
+
static #collectFooterHints(footerTexts) {
|
|
152
|
+
const rows = SchematicTitleBlockParser.#groupRows(footerTexts)
|
|
153
|
+
const topRow = rows[0] || []
|
|
154
|
+
const middleRow = rows.length > 2 ? rows[1] || [] : []
|
|
155
|
+
const sheetRow =
|
|
156
|
+
[...rows]
|
|
157
|
+
.reverse()
|
|
158
|
+
.find(
|
|
159
|
+
(row) =>
|
|
160
|
+
row.filter((record) => /^\d+$/.test(record.text))
|
|
161
|
+
.length >= 2
|
|
162
|
+
) || []
|
|
163
|
+
const numericSheetRow = sheetRow.filter((record) =>
|
|
164
|
+
/^\d+$/.test(record.text)
|
|
165
|
+
)
|
|
166
|
+
const topRowHasVisibleTitleText = topRow.some(
|
|
167
|
+
(record) => /^\d+$/.test(record.text) === false
|
|
168
|
+
)
|
|
169
|
+
const topRowDataRecords = topRow.filter(
|
|
170
|
+
(record) => !SchematicTitleBlockParser.#isLabelText(record.text)
|
|
171
|
+
)
|
|
172
|
+
const middleRowDataRecords = middleRow.filter(
|
|
173
|
+
(record) => !SchematicTitleBlockParser.#isLabelText(record.text)
|
|
174
|
+
)
|
|
175
|
+
const hints = {}
|
|
176
|
+
const titleHint = SchematicTitleBlockParser.#findSpecialStringHint(
|
|
177
|
+
footerTexts,
|
|
178
|
+
['title']
|
|
179
|
+
)
|
|
180
|
+
const documentNumberHint =
|
|
181
|
+
SchematicTitleBlockParser.#findSpecialStringHint(footerTexts, [
|
|
182
|
+
'documentnumber'
|
|
183
|
+
])
|
|
184
|
+
const revisionHint = SchematicTitleBlockParser.#findSpecialStringHint(
|
|
185
|
+
footerTexts,
|
|
186
|
+
['revision']
|
|
187
|
+
)
|
|
188
|
+
const sheetNumberHint =
|
|
189
|
+
SchematicTitleBlockParser.#findSpecialStringHint(footerTexts, [
|
|
190
|
+
'sheetnumber'
|
|
191
|
+
])
|
|
192
|
+
const sheetTotalHint = SchematicTitleBlockParser.#findSpecialStringHint(
|
|
193
|
+
footerTexts,
|
|
194
|
+
['sheettotal']
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (titleHint) {
|
|
198
|
+
hints.title = titleHint
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (documentNumberHint) {
|
|
202
|
+
hints.documentNumber = documentNumberHint
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (revisionHint) {
|
|
206
|
+
hints.revision = revisionHint
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (sheetNumberHint) {
|
|
210
|
+
hints.sheetNumber = sheetNumberHint
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (sheetTotalHint) {
|
|
214
|
+
hints.sheetTotal = sheetTotalHint
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
!hints.title &&
|
|
219
|
+
topRowDataRecords.length &&
|
|
220
|
+
topRowHasVisibleTitleText
|
|
221
|
+
) {
|
|
222
|
+
hints.title = topRowDataRecords[0]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
!hints.documentNumber &&
|
|
227
|
+
topRowDataRecords.length > 1 &&
|
|
228
|
+
topRowHasVisibleTitleText
|
|
229
|
+
) {
|
|
230
|
+
hints.documentNumber = topRowDataRecords.at(-1)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!hints.revision && middleRowDataRecords.length) {
|
|
234
|
+
hints.revision = middleRowDataRecords.at(-1)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!hints.sheetNumber && numericSheetRow.length) {
|
|
238
|
+
hints.sheetNumber = numericSheetRow[0]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!hints.sheetTotal && numericSheetRow.length) {
|
|
242
|
+
hints.sheetTotal = numericSheetRow.at(-1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return hints
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Finds the first footer text that directly references one special string.
|
|
250
|
+
* @param {{ text: string }[]} footerTexts Footer text rows.
|
|
251
|
+
* @param {string[]} names Lowercase special-string names.
|
|
252
|
+
* @returns {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string } | undefined}
|
|
253
|
+
*/
|
|
254
|
+
static #findSpecialStringHint(footerTexts, names) {
|
|
255
|
+
const acceptedNames = new Set(names)
|
|
256
|
+
|
|
257
|
+
return (footerTexts || []).find((record) =>
|
|
258
|
+
acceptedNames.has(
|
|
259
|
+
SchematicTitleBlockParser.#specialStringName(record.text)
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extracts a direct Altium special-string name from footer text.
|
|
266
|
+
* @param {string} text Footer text.
|
|
267
|
+
* @returns {string}
|
|
268
|
+
*/
|
|
269
|
+
static #specialStringName(text) {
|
|
270
|
+
const match = String(text || '')
|
|
271
|
+
.trim()
|
|
272
|
+
.match(/^[.=]([A-Za-z_][\w.-]*)$/u)
|
|
273
|
+
|
|
274
|
+
return match ? match[1].toLowerCase() : ''
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Returns true for static native title-block label cells.
|
|
279
|
+
* @param {string} text Footer text.
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
static #isLabelText(text) {
|
|
283
|
+
return /^(title|size|number|revision|date|file|sheet|of|drawn by|project):?$/iu.test(
|
|
284
|
+
String(text || '').trim()
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Groups footer texts by their shared baseline row.
|
|
290
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
291
|
+
* @returns {Array<{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]>}
|
|
292
|
+
*/
|
|
293
|
+
static #groupRows(footerTexts) {
|
|
294
|
+
const tolerance = 8
|
|
295
|
+
const rows = []
|
|
296
|
+
|
|
297
|
+
for (const record of footerTexts) {
|
|
298
|
+
const currentRow = rows.at(-1)
|
|
299
|
+
|
|
300
|
+
if (
|
|
301
|
+
currentRow &&
|
|
302
|
+
Math.abs(currentRow[0].y - record.y) <= tolerance
|
|
303
|
+
) {
|
|
304
|
+
currentRow.push(record)
|
|
305
|
+
currentRow.sort((left, right) => left.x - right.x)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
rows.push([record])
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return rows
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolves one visible footer placeholder against hidden sheet metadata.
|
|
317
|
+
* @param {string | undefined} text
|
|
318
|
+
* @param {Record<string, string>} metadata
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
static #resolveFooterValue(text, metadata) {
|
|
322
|
+
const resolved = SchematicTitleBlockParser.#resolveTemplateText(
|
|
323
|
+
text,
|
|
324
|
+
metadata
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if (String(resolved || '').startsWith('=')) {
|
|
328
|
+
return ''
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return SchematicTitleBlockParser.#cleanMetadataValue(resolved)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Extracts a visible footer `Drawn By` value from the bottom-most footer
|
|
336
|
+
* row when hidden metadata does not provide one.
|
|
337
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
338
|
+
* @param {Record<string, string>} metadata
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
static #extractDrawnBy(footerTexts, metadata) {
|
|
342
|
+
const bottomRow =
|
|
343
|
+
SchematicTitleBlockParser.#groupRows(footerTexts).at(-1) || []
|
|
344
|
+
const candidates = bottomRow
|
|
345
|
+
.map((record) =>
|
|
346
|
+
SchematicTitleBlockParser.#resolveFooterValue(
|
|
347
|
+
record.text,
|
|
348
|
+
metadata
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
.filter(
|
|
352
|
+
(value) => value && /^\d+$/.test(String(value).trim()) === false
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return candidates.at(-1) || ''
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Removes the non-rendered text payload from stored footer hints.
|
|
360
|
+
* @param {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>} footerHints
|
|
361
|
+
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>}
|
|
362
|
+
*/
|
|
363
|
+
static #stripHintText(footerHints) {
|
|
364
|
+
return Object.fromEntries(
|
|
365
|
+
Object.entries(footerHints).map(([key, value]) => {
|
|
366
|
+
const { text: _text, ...hint } = value
|
|
367
|
+
return [key, hint]
|
|
368
|
+
})
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolves visible title-block placeholders from hidden sheet metadata.
|
|
374
|
+
* @param {string | undefined} text
|
|
375
|
+
* @param {Record<string, string>} metadata
|
|
376
|
+
* @returns {string}
|
|
377
|
+
*/
|
|
378
|
+
static #resolveTemplateText(text, metadata) {
|
|
379
|
+
const normalized = String(text || '').trim()
|
|
380
|
+
if (!normalized.startsWith('=')) {
|
|
381
|
+
return normalized
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const replacement = metadata[normalized.slice(1).toLowerCase()]
|
|
385
|
+
return replacement ? replacement : normalized
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Normalizes placeholder metadata values.
|
|
390
|
+
* @param {string | undefined} value
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
static #cleanMetadataValue(value) {
|
|
394
|
+
return value && value !== '*' ? value : ''
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Returns the default schematic font when no sheet font entry exists.
|
|
399
|
+
* @returns {{ size: number, family: string, bold: boolean, italic: boolean, rotation: number }}
|
|
400
|
+
*/
|
|
401
|
+
static #defaultFont() {
|
|
402
|
+
return {
|
|
403
|
+
size: 10,
|
|
404
|
+
family: 'Times New Roman',
|
|
405
|
+
bold: false,
|
|
406
|
+
italic: false,
|
|
407
|
+
rotation: 0
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { AltiumPcbLibExporter } from './AltiumPcbLibExporter.mjs'
|
|
6
|
+
import { AltiumSchLibExporter } from './AltiumSchLibExporter.mjs'
|
|
7
|
+
import { SourceBundleExporter } from './SourceBundleExporter.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds Altium library exports from normalized selected-part data.
|
|
11
|
+
*/
|
|
12
|
+
export class SelectedPartAltiumExportAdapter {
|
|
13
|
+
/**
|
|
14
|
+
* Exports one selected-part bundle to Altium library entries.
|
|
15
|
+
* @param {object} selectedPart Selected part data.
|
|
16
|
+
* @param {{ models?: object[], partName?: string, libraryBasePath?: string, sourceBasePath?: string }} [options] Export options.
|
|
17
|
+
* @returns {{ bundle: object, entries: { path: string, bytes: Uint8Array, contentType: string }[] }}
|
|
18
|
+
*/
|
|
19
|
+
static export(selectedPart, options = {}) {
|
|
20
|
+
const partName = SelectedPartAltiumExportAdapter.#safeFileName(
|
|
21
|
+
options.partName ||
|
|
22
|
+
selectedPart?.footprint?.name ||
|
|
23
|
+
selectedPart?.symbol?.name ||
|
|
24
|
+
selectedPart?.designator ||
|
|
25
|
+
'selected-part'
|
|
26
|
+
)
|
|
27
|
+
const bundle = SelectedPartAltiumExportAdapter.buildBundle(
|
|
28
|
+
selectedPart,
|
|
29
|
+
options.models,
|
|
30
|
+
partName
|
|
31
|
+
)
|
|
32
|
+
const libraryBasePath = SelectedPartAltiumExportAdapter.#basePath(
|
|
33
|
+
options.libraryBasePath,
|
|
34
|
+
'altium'
|
|
35
|
+
)
|
|
36
|
+
const sourceBasePath = SelectedPartAltiumExportAdapter.#basePath(
|
|
37
|
+
options.sourceBasePath,
|
|
38
|
+
'source'
|
|
39
|
+
)
|
|
40
|
+
const sourceEntries = SourceBundleExporter.export(bundle, {
|
|
41
|
+
includeModels: true
|
|
42
|
+
}).entries.map((entry) => ({
|
|
43
|
+
...entry,
|
|
44
|
+
path:
|
|
45
|
+
entry.path === 'manifest.json'
|
|
46
|
+
? SelectedPartAltiumExportAdapter.#joinPath(
|
|
47
|
+
sourceBasePath,
|
|
48
|
+
'manifest.json'
|
|
49
|
+
)
|
|
50
|
+
: entry.path
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
bundle,
|
|
55
|
+
entries: [
|
|
56
|
+
{
|
|
57
|
+
path: SelectedPartAltiumExportAdapter.#joinPath(
|
|
58
|
+
libraryBasePath,
|
|
59
|
+
partName + '.SchLib'
|
|
60
|
+
),
|
|
61
|
+
bytes: AltiumSchLibExporter.export([bundle]),
|
|
62
|
+
contentType: 'application/octet-stream'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
path: SelectedPartAltiumExportAdapter.#joinPath(
|
|
66
|
+
libraryBasePath,
|
|
67
|
+
partName + '.PcbLib'
|
|
68
|
+
),
|
|
69
|
+
bytes: AltiumPcbLibExporter.export([bundle]),
|
|
70
|
+
contentType: 'application/octet-stream'
|
|
71
|
+
},
|
|
72
|
+
...sourceEntries
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds a source-compatible component bundle from selected-part data.
|
|
79
|
+
* @param {object} selectedPart Selected part data.
|
|
80
|
+
* @param {object[]} [models] Packaged 3D model assets.
|
|
81
|
+
* @param {string} [partName] Export artifact name.
|
|
82
|
+
* @returns {object}
|
|
83
|
+
*/
|
|
84
|
+
static buildBundle(selectedPart, models = [], partName = '') {
|
|
85
|
+
const bundleName =
|
|
86
|
+
partName || selectedPart?.designator || 'Selected part'
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: selectedPart?.designator || 'selected-part',
|
|
90
|
+
name: bundleName,
|
|
91
|
+
metadata: {
|
|
92
|
+
name: bundleName,
|
|
93
|
+
partNumber: selectedPart?.symbol?.value || ''
|
|
94
|
+
},
|
|
95
|
+
symbol: {
|
|
96
|
+
name:
|
|
97
|
+
selectedPart?.symbol?.name ||
|
|
98
|
+
selectedPart?.designator ||
|
|
99
|
+
'Selected part',
|
|
100
|
+
pins: selectedPart?.symbol?.pins || [],
|
|
101
|
+
primitives: SelectedPartAltiumExportAdapter.#symbolPrimitives(
|
|
102
|
+
selectedPart?.symbol
|
|
103
|
+
),
|
|
104
|
+
raw: selectedPart?.symbol?.raw || {}
|
|
105
|
+
},
|
|
106
|
+
footprint: {
|
|
107
|
+
name:
|
|
108
|
+
selectedPart?.footprint?.name ||
|
|
109
|
+
selectedPart?.designator ||
|
|
110
|
+
'Selected part',
|
|
111
|
+
component: selectedPart?.footprint?.component || {},
|
|
112
|
+
pads: selectedPart?.footprint?.pads || [],
|
|
113
|
+
tracks: selectedPart?.footprint?.tracks || [],
|
|
114
|
+
arcs: selectedPart?.footprint?.arcs || [],
|
|
115
|
+
fills: selectedPart?.footprint?.fills || [],
|
|
116
|
+
texts: selectedPart?.footprint?.texts || [],
|
|
117
|
+
primitives: [],
|
|
118
|
+
raw: selectedPart?.footprint?.raw || {}
|
|
119
|
+
},
|
|
120
|
+
models: SelectedPartAltiumExportAdapter.#array(models),
|
|
121
|
+
sourceJson: {
|
|
122
|
+
selectedPart
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds Altium schematic primitive rows from the selected symbol.
|
|
129
|
+
* @param {object} symbol Selected symbol.
|
|
130
|
+
* @returns {object[]}
|
|
131
|
+
*/
|
|
132
|
+
static #symbolPrimitives(symbol = {}) {
|
|
133
|
+
return [
|
|
134
|
+
...SelectedPartAltiumExportAdapter.#typedPrimitives(
|
|
135
|
+
symbol?.rectangles,
|
|
136
|
+
'rectangle'
|
|
137
|
+
),
|
|
138
|
+
...SelectedPartAltiumExportAdapter.#typedPrimitives(
|
|
139
|
+
symbol?.lines,
|
|
140
|
+
'line'
|
|
141
|
+
),
|
|
142
|
+
...SelectedPartAltiumExportAdapter.#typedPrimitives(
|
|
143
|
+
symbol?.arcs,
|
|
144
|
+
'arc'
|
|
145
|
+
),
|
|
146
|
+
...SelectedPartAltiumExportAdapter.#typedPrimitives(
|
|
147
|
+
symbol?.ellipses,
|
|
148
|
+
'ellipse'
|
|
149
|
+
),
|
|
150
|
+
...SelectedPartAltiumExportAdapter.#typedPrimitives(
|
|
151
|
+
symbol?.polygons,
|
|
152
|
+
'polygon'
|
|
153
|
+
)
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Adds a primitive type to each entry.
|
|
159
|
+
* @param {unknown} primitives Primitive candidates.
|
|
160
|
+
* @param {string} type Primitive type.
|
|
161
|
+
* @returns {object[]}
|
|
162
|
+
*/
|
|
163
|
+
static #typedPrimitives(primitives, type) {
|
|
164
|
+
return SelectedPartAltiumExportAdapter.#array(primitives).map(
|
|
165
|
+
(primitive) => ({
|
|
166
|
+
type,
|
|
167
|
+
...primitive
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Normalizes an export base path.
|
|
174
|
+
* @param {unknown} value Candidate path.
|
|
175
|
+
* @param {string} fallback Fallback path.
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
static #basePath(value, fallback) {
|
|
179
|
+
return String(value || fallback)
|
|
180
|
+
.replace(/^\/+/u, '')
|
|
181
|
+
.replace(/\/+$/u, '')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Joins one base path and file path.
|
|
186
|
+
* @param {string} basePath Base path.
|
|
187
|
+
* @param {string} filePath File path.
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
static #joinPath(basePath, filePath) {
|
|
191
|
+
const cleanFilePath = String(filePath || '').replace(/^\/+/u, '')
|
|
192
|
+
return basePath ? basePath + '/' + cleanFilePath : cleanFilePath
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalizes a possible array.
|
|
197
|
+
* @param {unknown} value Candidate array.
|
|
198
|
+
* @returns {object[]}
|
|
199
|
+
*/
|
|
200
|
+
static #array(value) {
|
|
201
|
+
return Array.isArray(value) ? value : []
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Creates a safe file name.
|
|
206
|
+
* @param {unknown} value Raw value.
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
static #safeFileName(value) {
|
|
210
|
+
return String(value || 'selected-part').replace(
|
|
211
|
+
/[\\/:\u0000-\u001f]/gu,
|
|
212
|
+
'_'
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -137,10 +137,25 @@ export class SourceComponentBundleNormalizer {
|
|
|
137
137
|
primitives: SourceComponentBundleNormalizer.#array(
|
|
138
138
|
footprint.primitives || footprint.shapes
|
|
139
139
|
),
|
|
140
|
+
component:
|
|
141
|
+
SourceComponentBundleNormalizer.#normalizeFootprintComponent(
|
|
142
|
+
footprint.component || source.component
|
|
143
|
+
),
|
|
140
144
|
raw: footprint
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Preserves source footprint component placement metadata for exporters.
|
|
150
|
+
* @param {object | undefined} component Source component placement.
|
|
151
|
+
* @returns {object}
|
|
152
|
+
*/
|
|
153
|
+
static #normalizeFootprintComponent(component) {
|
|
154
|
+
return component && typeof component === 'object'
|
|
155
|
+
? { ...component }
|
|
156
|
+
: {}
|
|
157
|
+
}
|
|
158
|
+
|
|
144
159
|
/**
|
|
145
160
|
* Normalizes model asset descriptors.
|
|
146
161
|
* @param {object} source Unwrapped response.
|
|
@@ -177,6 +192,8 @@ export class SourceComponentBundleNormalizer {
|
|
|
177
192
|
)
|
|
178
193
|
const bytes =
|
|
179
194
|
SourceComponentBundleNormalizer.#normalizeModelBytes(model)
|
|
195
|
+
const transform =
|
|
196
|
+
SourceComponentBundleNormalizer.#normalizeModelTransform(model)
|
|
180
197
|
|
|
181
198
|
return {
|
|
182
199
|
id: SourceComponentBundleNormalizer.#firstString(
|
|
@@ -199,10 +216,24 @@ export class SourceComponentBundleNormalizer {
|
|
|
199
216
|
model.downloadUrl,
|
|
200
217
|
model.sourceUrl
|
|
201
218
|
),
|
|
219
|
+
...(transform ? { transform } : {}),
|
|
220
|
+
...(model.generated === true ? { generated: true } : {}),
|
|
202
221
|
raw: model
|
|
203
222
|
}
|
|
204
223
|
}
|
|
205
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Preserves optional model placement transforms.
|
|
227
|
+
* @param {object} model Raw model asset.
|
|
228
|
+
* @returns {object | null}
|
|
229
|
+
*/
|
|
230
|
+
static #normalizeModelTransform(model) {
|
|
231
|
+
const transform = model.transform || model.modelTransform || null
|
|
232
|
+
return transform && typeof transform === 'object'
|
|
233
|
+
? { ...transform }
|
|
234
|
+
: null
|
|
235
|
+
}
|
|
236
|
+
|
|
206
237
|
/**
|
|
207
238
|
* Normalizes one model payload into bytes.
|
|
208
239
|
* @param {object} model Raw model asset.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Defines the Circuit JSON contract emitted by public parser roots.
|
|
7
7
|
*/
|
|
8
8
|
export class CircuitJsonModelSchema {
|
|
9
|
-
static CURRENT_SCHEMA_ID = '
|
|
9
|
+
static CURRENT_SCHEMA_ID = 'urn:altium-toolkit:circuit-json:0.0.433'
|
|
10
10
|
|
|
11
11
|
static CURRENT_SCHEMA_VERSION = '0.0.433'
|
|
12
12
|
|
package/src/parser.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export { AltiumLibraryBatchExporter } from './core/altium/AltiumLibraryBatchExpo
|
|
|
11
11
|
export { AltiumLibraryRecordBuilder } from './core/altium/AltiumLibraryRecordBuilder.mjs'
|
|
12
12
|
export { AltiumPcbLibExporter } from './core/altium/AltiumPcbLibExporter.mjs'
|
|
13
13
|
export { AltiumSchLibExporter } from './core/altium/AltiumSchLibExporter.mjs'
|
|
14
|
+
export { SelectedPartAltiumExportAdapter } from './core/altium/SelectedPartAltiumExportAdapter.mjs'
|
|
14
15
|
export { AltiumUnits } from './core/altium/AltiumUnits.mjs'
|
|
15
16
|
export { SourceBundleExporter } from './core/altium/SourceBundleExporter.mjs'
|
|
16
17
|
export { SourceComponentBundleNormalizer } from './core/altium/SourceComponentBundleNormalizer.mjs'
|