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.
- 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/AltiumLibraryBatchExporter.mjs +206 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
- package/src/core/altium/AltiumParser.mjs +5 -2
- 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/PcbEmbeddedModelExtractor.mjs +22 -3
- package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
- package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
- package/src/core/altium/SchematicPinParser.mjs +87 -20
- package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
- package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
- 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 +185 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
- package/src/ui/SchematicLineColorResolver.mjs +88 -0
- package/src/ui/SchematicNoteRenderer.mjs +5 -1
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
- package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
- package/src/ui/SchematicShapeRenderer.mjs +37 -11
- package/src/ui/SchematicSvgRenderer.mjs +944 -51
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
import { SchematicNoErcSymbolResolver } from './SchematicNoErcSymbolResolver.mjs'
|
|
6
7
|
import { SchematicPinDesignatorInferer } from './SchematicPinDesignatorInferer.mjs'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -66,11 +67,9 @@ export class SchematicPinParser {
|
|
|
66
67
|
),
|
|
67
68
|
designator: ParserUtils.getField(record.fields, 'Designator'),
|
|
68
69
|
orientation,
|
|
69
|
-
electrical:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
'Electrical'
|
|
73
|
-
) || undefined,
|
|
70
|
+
electrical: SchematicPinParser.#parseSchematicPinElectrical(
|
|
71
|
+
record.fields
|
|
72
|
+
),
|
|
74
73
|
symbolOuter:
|
|
75
74
|
ParserUtils.parseNumericField(
|
|
76
75
|
record.fields,
|
|
@@ -94,6 +93,30 @@ export class SchematicPinParser {
|
|
|
94
93
|
)
|
|
95
94
|
}
|
|
96
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Parses Altium's pin electrical type, including its omitted-field default.
|
|
98
|
+
* Formal schematic pin records omit Electrical for input pins and serialize
|
|
99
|
+
* passive pins explicitly as Electrical=4.
|
|
100
|
+
* @param {Record<string, string | string[]>} fields Pin record fields.
|
|
101
|
+
* @returns {number | undefined}
|
|
102
|
+
*/
|
|
103
|
+
static #parseSchematicPinElectrical(fields) {
|
|
104
|
+
const explicitElectrical = ParserUtils.parseNumericField(
|
|
105
|
+
fields,
|
|
106
|
+
'Electrical'
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (explicitElectrical !== null) {
|
|
110
|
+
return explicitElectrical
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (ParserUtils.parseNumericField(fields, 'FormalType') !== null) {
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
/**
|
|
98
121
|
* Normalizes schematic port records into drawable port boxes.
|
|
99
122
|
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
@@ -386,24 +409,36 @@ export class SchematicPinParser {
|
|
|
386
409
|
/**
|
|
387
410
|
* Normalizes no-connect crosses from schematic records.
|
|
388
411
|
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
389
|
-
* @returns {{ x: number, y: number, size: number, color: string }[]}
|
|
412
|
+
* @returns {{ x: number, y: number, size: number, color: string, symbol: number | null, symbolName: string }[]}
|
|
390
413
|
*/
|
|
391
414
|
static parseSchematicCrosses(records) {
|
|
392
415
|
return records
|
|
393
|
-
.map((record) =>
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
416
|
+
.map((record) => {
|
|
417
|
+
const rawSymbol = ParserUtils.getField(record.fields, 'Symbol')
|
|
418
|
+
const symbol = ParserUtils.parseNumericField(
|
|
419
|
+
record.fields,
|
|
420
|
+
'Symbol'
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
x:
|
|
425
|
+
ParserUtils.parseNumericField(
|
|
426
|
+
record.fields,
|
|
427
|
+
'Location.X'
|
|
428
|
+
) || 0,
|
|
429
|
+
y:
|
|
430
|
+
ParserUtils.parseNumericField(
|
|
431
|
+
record.fields,
|
|
432
|
+
'Location.Y'
|
|
433
|
+
) || 0,
|
|
434
|
+
size: 6,
|
|
435
|
+
color: ParserUtils.toColor(record.fields.Color, '#ff0000'),
|
|
436
|
+
symbol,
|
|
437
|
+
symbolName: SchematicNoErcSymbolResolver.resolveSymbolName(
|
|
438
|
+
rawSymbol || symbol
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
})
|
|
407
442
|
.filter((cross) => cross.x || cross.y)
|
|
408
443
|
}
|
|
409
444
|
|
|
@@ -816,6 +851,13 @@ export class SchematicPinParser {
|
|
|
816
851
|
SchematicPinParser.#isTwoPinNumericEndpointGroup(normalizedPins)
|
|
817
852
|
) {
|
|
818
853
|
labelMode = 'number-only'
|
|
854
|
+
} else if (
|
|
855
|
+
SchematicPinParser.#isCompactSinglePinMarkerGroup(
|
|
856
|
+
normalizedPins,
|
|
857
|
+
names
|
|
858
|
+
)
|
|
859
|
+
) {
|
|
860
|
+
labelMode = 'hidden'
|
|
819
861
|
} else if (allPassive && normalizedPins.length <= 2) {
|
|
820
862
|
labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
|
|
821
863
|
normalizedPins
|
|
@@ -875,6 +917,31 @@ export class SchematicPinParser {
|
|
|
875
917
|
}))
|
|
876
918
|
}
|
|
877
919
|
|
|
920
|
+
/**
|
|
921
|
+
* Returns true when a single owner pin belongs to compact marker artwork
|
|
922
|
+
* rather than to a visibly numbered electrical contact.
|
|
923
|
+
* @param {{ designator: string, name: string, length: number, electrical?: number }[]} pins
|
|
924
|
+
* @param {string[]} names
|
|
925
|
+
* @returns {boolean}
|
|
926
|
+
*/
|
|
927
|
+
static #isCompactSinglePinMarkerGroup(pins, names) {
|
|
928
|
+
if (pins.length !== 1 || names.length !== 0) {
|
|
929
|
+
return false
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const pin = pins[0]
|
|
933
|
+
const designator = String(pin.designator || '').trim()
|
|
934
|
+
const length = Math.abs(Number(pin.length || 0))
|
|
935
|
+
const electrical = Number(pin.electrical)
|
|
936
|
+
|
|
937
|
+
return (
|
|
938
|
+
/^\d+$/.test(designator) &&
|
|
939
|
+
length > 0 &&
|
|
940
|
+
length <= 15 &&
|
|
941
|
+
(!Number.isFinite(electrical) || electrical === 4)
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
|
|
878
945
|
/**
|
|
879
946
|
* Returns true when a compact owner-drawn FET body uses semantic terminal
|
|
880
947
|
* names internally but still exposes external numeric contact labels.
|
|
@@ -98,6 +98,18 @@ export class SchematicPrimitiveParser {
|
|
|
98
98
|
)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Returns true when one point-listed record carries only an axis-aligned
|
|
103
|
+
* rectangular point list without authored `Location` and `Corner` fields.
|
|
104
|
+
* @param {Record<string, string | string[]>} fields
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
static isPointListedRectangleRecord(fields) {
|
|
108
|
+
return Boolean(
|
|
109
|
+
SchematicPrimitiveParser.#pointListedRectangleBounds(fields)
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
/**
|
|
102
114
|
* Normalizes record-7 polygon primitives into fill-capable polygons.
|
|
103
115
|
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
@@ -352,16 +364,32 @@ export class SchematicPrimitiveParser {
|
|
|
352
364
|
static parseSchematicRectangles(records) {
|
|
353
365
|
return records
|
|
354
366
|
.map((record, index) => {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
367
|
+
const pointListedBounds =
|
|
368
|
+
SchematicPrimitiveParser.#pointListedRectangleBounds(
|
|
369
|
+
record.fields
|
|
370
|
+
)
|
|
371
|
+
const x1 =
|
|
372
|
+
parseNumericField(record.fields, 'Location.X') ??
|
|
373
|
+
pointListedBounds?.minX ??
|
|
374
|
+
null
|
|
375
|
+
const y1 =
|
|
376
|
+
parseNumericField(record.fields, 'Location.Y') ??
|
|
377
|
+
pointListedBounds?.minY ??
|
|
378
|
+
null
|
|
379
|
+
const x2 =
|
|
380
|
+
parseNumericField(record.fields, 'Corner.X') ??
|
|
381
|
+
pointListedBounds?.maxX ??
|
|
382
|
+
null
|
|
383
|
+
const y2 =
|
|
384
|
+
parseNumericField(record.fields, 'Corner.Y') ??
|
|
385
|
+
pointListedBounds?.maxY ??
|
|
386
|
+
null
|
|
359
387
|
const isRectangleRecord =
|
|
360
388
|
SchematicPrimitiveParser.isRectangleRecord(record.fields)
|
|
361
389
|
const isListedRectangle =
|
|
362
390
|
SchematicPrimitiveParser.isListedRectangleRecord(
|
|
363
391
|
record.fields
|
|
364
|
-
)
|
|
392
|
+
) || Boolean(pointListedBounds)
|
|
365
393
|
const usesFrameFallback =
|
|
366
394
|
SchematicPrimitiveParser.#shouldUseFrameFallback(
|
|
367
395
|
record.fields,
|
|
@@ -402,9 +430,15 @@ export class SchematicPrimitiveParser {
|
|
|
402
430
|
: parseBoolean(record.fields.Transparent),
|
|
403
431
|
lineWidth:
|
|
404
432
|
parseNumericField(record.fields, 'LineWidth') || 1,
|
|
405
|
-
lineStyle:
|
|
406
|
-
|
|
407
|
-
|
|
433
|
+
lineStyle:
|
|
434
|
+
usesFrameFallback ||
|
|
435
|
+
SchematicPrimitiveParser.#shouldDefaultBlanketDash(
|
|
436
|
+
record.fields,
|
|
437
|
+
pointListedBounds
|
|
438
|
+
)
|
|
439
|
+
? 1
|
|
440
|
+
: parseNumericField(record.fields, 'LineStyle') ||
|
|
441
|
+
0,
|
|
408
442
|
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
409
443
|
record.fields,
|
|
410
444
|
index
|
|
@@ -989,6 +1023,80 @@ export class SchematicPrimitiveParser {
|
|
|
989
1023
|
)
|
|
990
1024
|
}
|
|
991
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Returns true when a point-listed blanket should use Altium's dashed frame
|
|
1028
|
+
* style even when the printable record omits `LineStyle`.
|
|
1029
|
+
* @param {Record<string, string | string[]>} fields Record fields.
|
|
1030
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} pointListedBounds Point-listed bounds.
|
|
1031
|
+
* @returns {boolean}
|
|
1032
|
+
*/
|
|
1033
|
+
static #shouldDefaultBlanketDash(fields, pointListedBounds) {
|
|
1034
|
+
return (
|
|
1035
|
+
Boolean(pointListedBounds) &&
|
|
1036
|
+
getField(fields, 'RECORD') === '225' &&
|
|
1037
|
+
getField(fields, 'LineStyle') === ''
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Resolves rectangle bounds from a point-listed axis-aligned frame.
|
|
1043
|
+
* @param {Record<string, string | string[]>} fields Record fields.
|
|
1044
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
1045
|
+
*/
|
|
1046
|
+
static #pointListedRectangleBounds(fields) {
|
|
1047
|
+
const points = SchematicPrimitiveParser.#normalizeClosedPointList(
|
|
1048
|
+
SchematicPrimitiveParser.#collectPolygonPoints(fields)
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
if (points.length !== 4) {
|
|
1052
|
+
return null
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const xs = [...new Set(points.map((point) => point.x))]
|
|
1056
|
+
const ys = [...new Set(points.map((point) => point.y))]
|
|
1057
|
+
|
|
1058
|
+
if (xs.length !== 2 || ys.length !== 2) {
|
|
1059
|
+
return null
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const minX = Math.min(...xs)
|
|
1063
|
+
const maxX = Math.max(...xs)
|
|
1064
|
+
const minY = Math.min(...ys)
|
|
1065
|
+
const maxY = Math.max(...ys)
|
|
1066
|
+
const corners = new Set([
|
|
1067
|
+
minX + ':' + minY,
|
|
1068
|
+
minX + ':' + maxY,
|
|
1069
|
+
maxX + ':' + minY,
|
|
1070
|
+
maxX + ':' + maxY
|
|
1071
|
+
])
|
|
1072
|
+
|
|
1073
|
+
if (!points.every((point) => corners.has(point.x + ':' + point.y))) {
|
|
1074
|
+
return null
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return { minX, minY, maxX, maxY }
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Removes an optional repeated closing point from one source point list.
|
|
1082
|
+
* @param {{ x: number, y: number }[]} points Source points.
|
|
1083
|
+
* @returns {{ x: number, y: number }[]}
|
|
1084
|
+
*/
|
|
1085
|
+
static #normalizeClosedPointList(points) {
|
|
1086
|
+
if (points.length < 2) {
|
|
1087
|
+
return points
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const first = points[0]
|
|
1091
|
+
const last = points.at(-1)
|
|
1092
|
+
|
|
1093
|
+
if (first.x === last.x && first.y === last.y) {
|
|
1094
|
+
return points.slice(0, -1)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return points
|
|
1098
|
+
}
|
|
1099
|
+
|
|
992
1100
|
/**
|
|
993
1101
|
* Collects one record-7 polygon point list in source order.
|
|
994
1102
|
* @param {Record<string, string | string[]>} fields
|
|
@@ -12,6 +12,8 @@ import { OleConstants } from '../ole/OleConstants.mjs'
|
|
|
12
12
|
* containers.
|
|
13
13
|
*/
|
|
14
14
|
export class SchematicStreamExtractor {
|
|
15
|
+
static #AUXILIARY_PRINTABLE_STREAMS = new Set(['Additional'])
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* Returns true when one buffer starts with the OLE compound-document
|
|
17
19
|
* signature.
|
|
@@ -38,9 +40,10 @@ export class SchematicStreamExtractor {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
* Extracts schematic records from the logical `FileHeader` stream
|
|
43
|
+
* Extracts schematic records from the logical `FileHeader` stream and
|
|
44
|
+
* Altium's auxiliary printable schematic streams.
|
|
42
45
|
* @param {ArrayBuffer} arrayBuffer
|
|
43
|
-
* @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[] } | null}
|
|
46
|
+
* @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], embeddedFiles?: object } | null}
|
|
44
47
|
*/
|
|
45
48
|
static extractFromArrayBuffer(arrayBuffer) {
|
|
46
49
|
if (!SchematicStreamExtractor.isCompoundDocument(arrayBuffer)) {
|
|
@@ -55,20 +58,28 @@ export class SchematicStreamExtractor {
|
|
|
55
58
|
return null
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
let
|
|
61
|
+
let fileHeaderRecords
|
|
59
62
|
|
|
60
63
|
try {
|
|
61
|
-
|
|
64
|
+
fileHeaderRecords = SchematicStreamExtractor.#parseStreamRecords(
|
|
65
|
+
compoundDocument,
|
|
66
|
+
'FileHeader'
|
|
67
|
+
)
|
|
62
68
|
} catch {
|
|
63
69
|
return null
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const streamNames = compoundDocument.listStreams()
|
|
73
|
+
const auxiliaryStreamNames = streamNames.filter((streamName) =>
|
|
74
|
+
SchematicStreamExtractor.#isAuxiliaryPrintableStream(streamName)
|
|
75
|
+
)
|
|
76
|
+
const auxiliaryRecords = auxiliaryStreamNames.flatMap((streamName) =>
|
|
77
|
+
SchematicStreamExtractor.#parseStreamRecords(
|
|
78
|
+
compoundDocument,
|
|
79
|
+
streamName
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
const records = [...fileHeaderRecords, ...auxiliaryRecords]
|
|
72
83
|
|
|
73
84
|
if (!records.length) {
|
|
74
85
|
return null
|
|
@@ -76,18 +87,54 @@ export class SchematicStreamExtractor {
|
|
|
76
87
|
|
|
77
88
|
return {
|
|
78
89
|
records,
|
|
79
|
-
streamNames
|
|
90
|
+
streamNames,
|
|
80
91
|
embeddedFiles: EmbeddedFileInventoryBuilder.buildFromStreams(
|
|
81
92
|
new Map(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
streamNames.map((name) => [
|
|
94
|
+
name,
|
|
95
|
+
compoundDocument.getStream(name)
|
|
96
|
+
])
|
|
85
97
|
),
|
|
86
|
-
{
|
|
98
|
+
{
|
|
99
|
+
skipStreamNames: ['FileHeader', ...auxiliaryStreamNames]
|
|
100
|
+
}
|
|
87
101
|
)
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Parses printable records from one compound-document stream.
|
|
107
|
+
* @param {OleCompoundDocument} compoundDocument
|
|
108
|
+
* @param {string} streamName
|
|
109
|
+
* @returns {Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>}
|
|
110
|
+
*/
|
|
111
|
+
static #parseStreamRecords(compoundDocument, streamName) {
|
|
112
|
+
return AsciiRecordParser.parse(
|
|
113
|
+
SchematicStreamExtractor.#toArrayBuffer(
|
|
114
|
+
compoundDocument.getStream(streamName)
|
|
115
|
+
)
|
|
116
|
+
).map((record) => ({
|
|
117
|
+
...record,
|
|
118
|
+
sourceStream: streamName
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns true for known non-embedded schematic streams with printable
|
|
124
|
+
* primitive records.
|
|
125
|
+
* @param {string} streamName
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
static #isAuxiliaryPrintableStream(streamName) {
|
|
129
|
+
const leafName = String(streamName || '')
|
|
130
|
+
.split('/')
|
|
131
|
+
.at(-1)
|
|
132
|
+
|
|
133
|
+
return SchematicStreamExtractor.#AUXILIARY_PRINTABLE_STREAMS.has(
|
|
134
|
+
leafName
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
91
138
|
/**
|
|
92
139
|
* Returns an ArrayBuffer view over one byte slice.
|
|
93
140
|
* @param {Uint8Array} bytes
|
|
@@ -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
|
+
}
|