altium-toolkit 0.1.1 → 0.1.16
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/README.md +24 -6
- package/docs/api.md +42 -4
- package/docs/model-format.md +95 -5
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
- package/docs/testing.md +7 -2
- package/package.json +6 -2
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumParser.mjs +22 -325
- package/src/core/altium/NormalizedModelSchema.mjs +28 -0
- package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
- package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
- package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
- package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
- package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
- package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
- package/src/core/altium/PcbLibModelParser.mjs +202 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
- package/src/core/altium/PcbModelParser.mjs +618 -66
- package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
- package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
- package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
- package/src/core/altium/PcbPadStackParser.mjs +903 -0
- package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
- package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
- package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
- package/src/core/altium/PcbRuleParser.mjs +587 -0
- package/src/core/altium/PcbStreamExtractor.mjs +127 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
- package/src/core/altium/PcbViaStackParser.mjs +548 -0
- package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
- package/src/core/altium/PrjPcbModelParser.mjs +797 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
- package/src/parser.mjs +13 -0
- package/src/renderers.mjs +5 -0
- package/src/styles/altium-renderers.css +11 -6
- package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
- package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
- package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
- package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
- package/src/ui/PcbSvgRenderer.mjs +101 -109
- package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
- package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses minimal sfnt metric tables from recovered embedded PCB fonts.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbFontMetricsParser {
|
|
9
|
+
/**
|
|
10
|
+
* Parses font-family metrics from a TrueType/OpenType sfnt payload.
|
|
11
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
12
|
+
* @returns {{ format: 'truetype' | 'opentype' | 'unknown', unitsPerEm?: number, ascent?: number, descent?: number, lineGap?: number, cellHeight?: number, emScaleFromPcbHeight?: number, capHeight?: number, averageAdvanceWidth?: number, weightClass?: number, widthClass?: number }}
|
|
13
|
+
*/
|
|
14
|
+
static parse(bytes) {
|
|
15
|
+
const normalizedBytes = PcbFontMetricsParser.#toUint8Array(bytes)
|
|
16
|
+
const view = new DataView(
|
|
17
|
+
normalizedBytes.buffer,
|
|
18
|
+
normalizedBytes.byteOffset,
|
|
19
|
+
normalizedBytes.byteLength
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (normalizedBytes.byteLength < 12) {
|
|
23
|
+
return { format: 'unknown' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const format = PcbFontMetricsParser.#parseFormat(normalizedBytes)
|
|
27
|
+
if (format === 'unknown') {
|
|
28
|
+
return { format }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tables = PcbFontMetricsParser.#parseTableDirectory(view)
|
|
32
|
+
const head = PcbFontMetricsParser.#readHeadTable(
|
|
33
|
+
view,
|
|
34
|
+
tables.get('head')
|
|
35
|
+
)
|
|
36
|
+
const hhea = PcbFontMetricsParser.#readHheaTable(
|
|
37
|
+
view,
|
|
38
|
+
tables.get('hhea')
|
|
39
|
+
)
|
|
40
|
+
const os2 = PcbFontMetricsParser.#readOs2Table(view, tables.get('OS/2'))
|
|
41
|
+
const hmtx = PcbFontMetricsParser.#readHmtxTable(
|
|
42
|
+
view,
|
|
43
|
+
tables.get('hmtx'),
|
|
44
|
+
hhea.numberOfHMetrics
|
|
45
|
+
)
|
|
46
|
+
const cellHeight =
|
|
47
|
+
Number.isFinite(hhea.ascent) && Number.isFinite(hhea.descent)
|
|
48
|
+
? hhea.ascent + Math.abs(hhea.descent)
|
|
49
|
+
: undefined
|
|
50
|
+
const metrics = {
|
|
51
|
+
format,
|
|
52
|
+
...head,
|
|
53
|
+
...hhea,
|
|
54
|
+
cellHeight,
|
|
55
|
+
emScaleFromPcbHeight:
|
|
56
|
+
head.unitsPerEm && cellHeight
|
|
57
|
+
? head.unitsPerEm / cellHeight
|
|
58
|
+
: undefined,
|
|
59
|
+
...os2,
|
|
60
|
+
averageAdvanceWidth:
|
|
61
|
+
hmtx.averageAdvanceWidth || os2.averageAdvanceWidth
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(metrics).filter(
|
|
66
|
+
([key, value]) =>
|
|
67
|
+
key !== 'numberOfHMetrics' &&
|
|
68
|
+
value !== undefined &&
|
|
69
|
+
(typeof value === 'string' || Number.isFinite(value))
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the sfnt flavor from the first four bytes.
|
|
76
|
+
* @param {Uint8Array} bytes
|
|
77
|
+
* @returns {'truetype' | 'opentype' | 'unknown'}
|
|
78
|
+
*/
|
|
79
|
+
static #parseFormat(bytes) {
|
|
80
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 4))
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
bytes[0] === 0x00 &&
|
|
84
|
+
bytes[1] === 0x01 &&
|
|
85
|
+
bytes[2] === 0x00 &&
|
|
86
|
+
bytes[3] === 0x00
|
|
87
|
+
) {
|
|
88
|
+
return 'truetype'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (signature === 'true' || signature === 'typ1') {
|
|
92
|
+
return 'truetype'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (signature === 'OTTO') {
|
|
96
|
+
return 'opentype'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return 'unknown'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reads the sfnt table directory.
|
|
104
|
+
* @param {DataView} view
|
|
105
|
+
* @returns {Map<string, { offset: number, length: number }>}
|
|
106
|
+
*/
|
|
107
|
+
static #parseTableDirectory(view) {
|
|
108
|
+
const tables = new Map()
|
|
109
|
+
const tableCount = PcbFontMetricsParser.#readUint16(view, 4)
|
|
110
|
+
|
|
111
|
+
for (let index = 0; index < tableCount; index += 1) {
|
|
112
|
+
const recordOffset = 12 + index * 16
|
|
113
|
+
if (recordOffset + 16 > view.byteLength) {
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tag = PcbFontMetricsParser.#readTag(view, recordOffset)
|
|
118
|
+
const offset = PcbFontMetricsParser.#readUint32(
|
|
119
|
+
view,
|
|
120
|
+
recordOffset + 8
|
|
121
|
+
)
|
|
122
|
+
const length = PcbFontMetricsParser.#readUint32(
|
|
123
|
+
view,
|
|
124
|
+
recordOffset + 12
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if (offset + length <= view.byteLength) {
|
|
128
|
+
tables.set(tag, { offset, length })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return tables
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reads the units-per-em value from the `head` table.
|
|
137
|
+
* @param {DataView} view
|
|
138
|
+
* @param {{ offset: number, length: number } | undefined} table
|
|
139
|
+
* @returns {{ unitsPerEm?: number }}
|
|
140
|
+
*/
|
|
141
|
+
static #readHeadTable(view, table) {
|
|
142
|
+
if (!PcbFontMetricsParser.#tableHasBytes(table, 20)) {
|
|
143
|
+
return {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
unitsPerEm: PcbFontMetricsParser.#readUint16(
|
|
148
|
+
view,
|
|
149
|
+
table.offset + 18
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reads ascent, descent, and metric-count data from the `hhea` table.
|
|
156
|
+
* @param {DataView} view
|
|
157
|
+
* @param {{ offset: number, length: number } | undefined} table
|
|
158
|
+
* @returns {{ ascent?: number, descent?: number, lineGap?: number, numberOfHMetrics?: number }}
|
|
159
|
+
*/
|
|
160
|
+
static #readHheaTable(view, table) {
|
|
161
|
+
if (!PcbFontMetricsParser.#tableHasBytes(table, 36)) {
|
|
162
|
+
return {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
ascent: PcbFontMetricsParser.#readInt16(view, table.offset + 4),
|
|
167
|
+
descent: PcbFontMetricsParser.#readInt16(view, table.offset + 6),
|
|
168
|
+
lineGap: PcbFontMetricsParser.#readInt16(view, table.offset + 8),
|
|
169
|
+
numberOfHMetrics: PcbFontMetricsParser.#readUint16(
|
|
170
|
+
view,
|
|
171
|
+
table.offset + 34
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reads typography metadata from the `OS/2` table.
|
|
178
|
+
* @param {DataView} view
|
|
179
|
+
* @param {{ offset: number, length: number } | undefined} table
|
|
180
|
+
* @returns {{ averageAdvanceWidth?: number, weightClass?: number, widthClass?: number, capHeight?: number }}
|
|
181
|
+
*/
|
|
182
|
+
static #readOs2Table(view, table) {
|
|
183
|
+
if (!PcbFontMetricsParser.#tableHasBytes(table, 8)) {
|
|
184
|
+
return {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const version = PcbFontMetricsParser.#readUint16(view, table.offset)
|
|
188
|
+
const metrics = {
|
|
189
|
+
averageAdvanceWidth: PcbFontMetricsParser.#readInt16(
|
|
190
|
+
view,
|
|
191
|
+
table.offset + 2
|
|
192
|
+
),
|
|
193
|
+
weightClass: PcbFontMetricsParser.#readUint16(
|
|
194
|
+
view,
|
|
195
|
+
table.offset + 4
|
|
196
|
+
),
|
|
197
|
+
widthClass: PcbFontMetricsParser.#readUint16(view, table.offset + 6)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (version >= 2 && PcbFontMetricsParser.#tableHasBytes(table, 90)) {
|
|
201
|
+
metrics.capHeight = PcbFontMetricsParser.#readInt16(
|
|
202
|
+
view,
|
|
203
|
+
table.offset + 88
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return metrics
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reads horizontal advance data from the `hmtx` table.
|
|
212
|
+
* @param {DataView} view
|
|
213
|
+
* @param {{ offset: number, length: number } | undefined} table
|
|
214
|
+
* @param {number | undefined} numberOfHMetrics
|
|
215
|
+
* @returns {{ averageAdvanceWidth?: number }}
|
|
216
|
+
*/
|
|
217
|
+
static #readHmtxTable(view, table, numberOfHMetrics) {
|
|
218
|
+
if (!PcbFontMetricsParser.#tableHasBytes(table, 4)) {
|
|
219
|
+
return {}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const metricCount = Math.max(Number(numberOfHMetrics) || 1, 1)
|
|
223
|
+
const maxMetricCount = Math.min(
|
|
224
|
+
metricCount,
|
|
225
|
+
Math.floor(table.length / 4)
|
|
226
|
+
)
|
|
227
|
+
let totalAdvanceWidth = 0
|
|
228
|
+
|
|
229
|
+
for (let index = 0; index < maxMetricCount; index += 1) {
|
|
230
|
+
totalAdvanceWidth += PcbFontMetricsParser.#readUint16(
|
|
231
|
+
view,
|
|
232
|
+
table.offset + index * 4
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
averageAdvanceWidth: Math.round(totalAdvanceWidth / maxMetricCount)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Returns true when a table record contains at least the requested length.
|
|
243
|
+
* @param {{ offset: number, length: number } | undefined} table
|
|
244
|
+
* @param {number} minimumLength
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
static #tableHasBytes(table, minimumLength) {
|
|
248
|
+
return Boolean(table && table.length >= minimumLength)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reads one four-character table tag.
|
|
253
|
+
* @param {DataView} view
|
|
254
|
+
* @param {number} offset
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
static #readTag(view, offset) {
|
|
258
|
+
return String.fromCharCode(
|
|
259
|
+
view.getUint8(offset),
|
|
260
|
+
view.getUint8(offset + 1),
|
|
261
|
+
view.getUint8(offset + 2),
|
|
262
|
+
view.getUint8(offset + 3)
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Reads one big-endian unsigned 16-bit integer.
|
|
268
|
+
* @param {DataView} view
|
|
269
|
+
* @param {number} offset
|
|
270
|
+
* @returns {number}
|
|
271
|
+
*/
|
|
272
|
+
static #readUint16(view, offset) {
|
|
273
|
+
return offset + 2 <= view.byteLength ? view.getUint16(offset, false) : 0
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Reads one big-endian signed 16-bit integer.
|
|
278
|
+
* @param {DataView} view
|
|
279
|
+
* @param {number} offset
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
static #readInt16(view, offset) {
|
|
283
|
+
return offset + 2 <= view.byteLength ? view.getInt16(offset, false) : 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Reads one big-endian unsigned 32-bit integer.
|
|
288
|
+
* @param {DataView} view
|
|
289
|
+
* @param {number} offset
|
|
290
|
+
* @returns {number}
|
|
291
|
+
*/
|
|
292
|
+
static #readUint32(view, offset) {
|
|
293
|
+
return offset + 4 <= view.byteLength ? view.getUint32(offset, false) : 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Normalizes byte-like input into a Uint8Array view.
|
|
298
|
+
* @param {Uint8Array | ArrayBuffer} bytes
|
|
299
|
+
* @returns {Uint8Array}
|
|
300
|
+
*/
|
|
301
|
+
static #toUint8Array(bytes) {
|
|
302
|
+
if (bytes instanceof Uint8Array) {
|
|
303
|
+
return bytes
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return new Uint8Array(bytes || new ArrayBuffer(0))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mirrors normalized PCB primitive coordinates into SVG top-view space.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbGeometryFlipper {
|
|
9
|
+
/**
|
|
10
|
+
* Mirrors one normalized PCB model vertically.
|
|
11
|
+
* @param {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], regions?: { points?: object[], holes?: object[][] }[], shapeBasedRegions?: { points?: object[], holes?: object[][] }[], boardRegions?: { points?: object[], holes?: object[][] }[], texts?: { x: number, y: number, rotation?: number }[], components?: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }} pcb
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
static flipGeometryVertically(pcb) {
|
|
15
|
+
const outline = pcb?.boardOutline
|
|
16
|
+
const maxY =
|
|
17
|
+
Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
|
|
18
|
+
const mirrorY = (value) =>
|
|
19
|
+
Number(outline?.minY || 0) + maxY - Number(value || 0)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...pcb,
|
|
23
|
+
boardOutline: {
|
|
24
|
+
...outline,
|
|
25
|
+
segments: (outline?.segments || []).map((segment) =>
|
|
26
|
+
PcbGeometryFlipper.#flipSegment(segment, mirrorY)
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
polygons: (pcb?.polygons || []).map((polygon) => ({
|
|
30
|
+
...polygon,
|
|
31
|
+
segments: (polygon.segments || []).map((segment) =>
|
|
32
|
+
PcbGeometryFlipper.#flipSegment(segment, mirrorY)
|
|
33
|
+
)
|
|
34
|
+
})),
|
|
35
|
+
fills: PcbGeometryFlipper.#flipFills(pcb?.fills || [], mirrorY),
|
|
36
|
+
tracks: (pcb?.tracks || []).map((track) => ({
|
|
37
|
+
...track,
|
|
38
|
+
y1: mirrorY(track.y1),
|
|
39
|
+
y2: mirrorY(track.y2)
|
|
40
|
+
})),
|
|
41
|
+
arcs: (pcb?.arcs || []).map((arc) => ({
|
|
42
|
+
...arc,
|
|
43
|
+
y: mirrorY(arc.y),
|
|
44
|
+
startAngle: PcbGeometryFlipper.#normalizeAngle(
|
|
45
|
+
360 - Number(arc.startAngle || 0)
|
|
46
|
+
),
|
|
47
|
+
endAngle: PcbGeometryFlipper.#normalizeAngle(
|
|
48
|
+
360 - Number(arc.endAngle || 0)
|
|
49
|
+
)
|
|
50
|
+
})),
|
|
51
|
+
vias: (pcb?.vias || []).map((via) => ({
|
|
52
|
+
...via,
|
|
53
|
+
y: mirrorY(via.y)
|
|
54
|
+
})),
|
|
55
|
+
pads: (pcb?.pads || []).map((pad) =>
|
|
56
|
+
PcbGeometryFlipper.#flipPad(pad, mirrorY)
|
|
57
|
+
),
|
|
58
|
+
regions: PcbGeometryFlipper.#flipRegions(
|
|
59
|
+
pcb?.regions || [],
|
|
60
|
+
mirrorY
|
|
61
|
+
),
|
|
62
|
+
shapeBasedRegions: PcbGeometryFlipper.#flipRegions(
|
|
63
|
+
pcb?.shapeBasedRegions || [],
|
|
64
|
+
mirrorY
|
|
65
|
+
),
|
|
66
|
+
boardRegions: PcbGeometryFlipper.#flipRegions(
|
|
67
|
+
pcb?.boardRegions || [],
|
|
68
|
+
mirrorY
|
|
69
|
+
),
|
|
70
|
+
texts: (pcb?.texts || []).map((text) => ({
|
|
71
|
+
...text,
|
|
72
|
+
y: mirrorY(text.y),
|
|
73
|
+
rotation: PcbGeometryFlipper.#normalizeAngle(
|
|
74
|
+
360 - Number(text.rotation || 0)
|
|
75
|
+
)
|
|
76
|
+
})),
|
|
77
|
+
components: (pcb?.components || []).map((component) => ({
|
|
78
|
+
...component,
|
|
79
|
+
y: mirrorY(component.y),
|
|
80
|
+
rotation: PcbGeometryFlipper.#normalizeAngle(
|
|
81
|
+
360 - Number(component.rotation || 0)
|
|
82
|
+
)
|
|
83
|
+
}))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mirrors rectangular fill extents across the board Y axis.
|
|
89
|
+
* @param {{ y1: number, y2: number }[]} fills
|
|
90
|
+
* @param {(value: number) => number} mirrorY
|
|
91
|
+
* @returns {object[]}
|
|
92
|
+
*/
|
|
93
|
+
static #flipFills(fills, mirrorY) {
|
|
94
|
+
return fills.map((fill) => {
|
|
95
|
+
const y1 = mirrorY(fill.y1)
|
|
96
|
+
const y2 = mirrorY(fill.y2)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...fill,
|
|
100
|
+
y1: Math.min(y1, y2),
|
|
101
|
+
y2: Math.max(y1, y2)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mirrors one pad center and rotation across the board Y axis.
|
|
108
|
+
* @param {{ y: number, rotation?: number, holeRotation?: number | null }} pad
|
|
109
|
+
* @param {(value: number) => number} mirrorY
|
|
110
|
+
* @returns {object}
|
|
111
|
+
*/
|
|
112
|
+
static #flipPad(pad, mirrorY) {
|
|
113
|
+
return {
|
|
114
|
+
...pad,
|
|
115
|
+
y: mirrorY(pad.y),
|
|
116
|
+
rotation: PcbGeometryFlipper.#normalizeAngle(
|
|
117
|
+
360 - Number(pad.rotation || 0)
|
|
118
|
+
),
|
|
119
|
+
holeRotation:
|
|
120
|
+
pad?.holeRotation === null || pad?.holeRotation === undefined
|
|
121
|
+
? (pad?.holeRotation ?? null)
|
|
122
|
+
: PcbGeometryFlipper.#normalizeAngle(
|
|
123
|
+
360 - Number(pad.holeRotation || 0)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mirrors one outline or polygon segment across the board Y axis.
|
|
130
|
+
* @param {Record<string, number | string>} segment
|
|
131
|
+
* @param {(value: number) => number} mirrorY
|
|
132
|
+
* @returns {Record<string, number | string>}
|
|
133
|
+
*/
|
|
134
|
+
static #flipSegment(segment, mirrorY) {
|
|
135
|
+
if (segment.type !== 'arc') {
|
|
136
|
+
return {
|
|
137
|
+
...segment,
|
|
138
|
+
y1: mirrorY(Number(segment.y1 || 0)),
|
|
139
|
+
y2: mirrorY(Number(segment.y2 || 0))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const startAngle = Number(segment.startAngle || 0)
|
|
144
|
+
const endAngle = Number(segment.endAngle || 0)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...segment,
|
|
148
|
+
y1: mirrorY(Number(segment.y1 || 0)),
|
|
149
|
+
y2: mirrorY(Number(segment.y2 || 0)),
|
|
150
|
+
cy: mirrorY(Number(segment.cy || 0)),
|
|
151
|
+
startAngle: PcbGeometryFlipper.#normalizeAngle(360 - startAngle),
|
|
152
|
+
endAngle: PcbGeometryFlipper.#normalizeAngle(360 - endAngle)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mirrors filled region contours and holes across the board Y axis.
|
|
158
|
+
* @param {{ points?: object[], holes?: object[][] }[]} regions
|
|
159
|
+
* @param {(value: number) => number} mirrorY
|
|
160
|
+
* @returns {object[]}
|
|
161
|
+
*/
|
|
162
|
+
static #flipRegions(regions, mirrorY) {
|
|
163
|
+
return regions.map((region) => ({
|
|
164
|
+
...region,
|
|
165
|
+
points: PcbGeometryFlipper.#flipRegionPoints(
|
|
166
|
+
region.points || [],
|
|
167
|
+
mirrorY
|
|
168
|
+
),
|
|
169
|
+
holes: (region.holes || []).map((hole) =>
|
|
170
|
+
PcbGeometryFlipper.#flipRegionPoints(hole, mirrorY)
|
|
171
|
+
),
|
|
172
|
+
...(Array.isArray(region.bendingLines)
|
|
173
|
+
? {
|
|
174
|
+
bendingLines: PcbGeometryFlipper.#flipBendingLines(
|
|
175
|
+
region.bendingLines,
|
|
176
|
+
mirrorY
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
: {})
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Mirrors board-region bending-line endpoints across the board Y axis.
|
|
185
|
+
* @param {{ y1?: number | null, y2?: number | null }[]} bendingLines
|
|
186
|
+
* @param {(value: number) => number} mirrorY
|
|
187
|
+
* @returns {object[]}
|
|
188
|
+
*/
|
|
189
|
+
static #flipBendingLines(bendingLines, mirrorY) {
|
|
190
|
+
return bendingLines.map((line) => ({
|
|
191
|
+
...line,
|
|
192
|
+
y1:
|
|
193
|
+
line.y1 === null || line.y1 === undefined
|
|
194
|
+
? (line.y1 ?? null)
|
|
195
|
+
: mirrorY(line.y1),
|
|
196
|
+
y2:
|
|
197
|
+
line.y2 === null || line.y2 === undefined
|
|
198
|
+
? (line.y2 ?? null)
|
|
199
|
+
: mirrorY(line.y2)
|
|
200
|
+
}))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Mirrors one region point list across the board Y axis.
|
|
205
|
+
* @param {object[]} points
|
|
206
|
+
* @param {(value: number) => number} mirrorY
|
|
207
|
+
* @returns {object[]}
|
|
208
|
+
*/
|
|
209
|
+
static #flipRegionPoints(points, mirrorY) {
|
|
210
|
+
return points.map((point) => {
|
|
211
|
+
const nextPoint = {
|
|
212
|
+
...point,
|
|
213
|
+
y: mirrorY(point.y)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (point.centerY !== null && point.centerY !== undefined) {
|
|
217
|
+
nextPoint.centerY = mirrorY(point.centerY)
|
|
218
|
+
}
|
|
219
|
+
if (point.startAngle !== null && point.startAngle !== undefined) {
|
|
220
|
+
nextPoint.startAngle = PcbGeometryFlipper.#normalizeAngle(
|
|
221
|
+
360 - Number(point.startAngle || 0)
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
if (point.endAngle !== null && point.endAngle !== undefined) {
|
|
225
|
+
nextPoint.endAngle = PcbGeometryFlipper.#normalizeAngle(
|
|
226
|
+
360 - Number(point.endAngle || 0)
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return nextPoint
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes one circular angle into the [0, 360) range.
|
|
236
|
+
* @param {number} angle
|
|
237
|
+
* @returns {number}
|
|
238
|
+
*/
|
|
239
|
+
static #normalizeAngle(angle) {
|
|
240
|
+
const normalized = Number(angle || 0) % 360
|
|
241
|
+
|
|
242
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts Altium legacy and V7 saved layer identifiers into stable layer IDs.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbLayerIdCodec {
|
|
9
|
+
static #TOP_SIGNAL_LAYER_ID = 1
|
|
10
|
+
|
|
11
|
+
static #BOTTOM_SIGNAL_LAYER_ID = 32
|
|
12
|
+
|
|
13
|
+
static #INTERNAL_PLANE_1_LAYER_ID = 39
|
|
14
|
+
|
|
15
|
+
static #MECHANICAL_1_LAYER_ID = 57
|
|
16
|
+
|
|
17
|
+
static #SIGNAL_LAYER_PREFIX = 0x01000000
|
|
18
|
+
|
|
19
|
+
static #INTERNAL_PLANE_PREFIX = 0x01010000
|
|
20
|
+
|
|
21
|
+
static #MECHANICAL_LAYER_PREFIX = 0x01020000
|
|
22
|
+
|
|
23
|
+
static #SYSTEM_LAYER_PREFIX = 0x01030000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decodes a V7 saved layer id into the corresponding legacy layer id.
|
|
27
|
+
* @param {unknown} savedLayerId
|
|
28
|
+
* @returns {number | null}
|
|
29
|
+
*/
|
|
30
|
+
static legacyLayerIdFromV7SaveId(savedLayerId) {
|
|
31
|
+
const saved = Number(savedLayerId)
|
|
32
|
+
if (!Number.isInteger(saved) || saved === 0) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (saved >= 0x01000001 && saved <= 0x0100001f) {
|
|
37
|
+
return saved - PcbLayerIdCodec.#SIGNAL_LAYER_PREFIX
|
|
38
|
+
}
|
|
39
|
+
if (saved === 0x0100ffff) {
|
|
40
|
+
return PcbLayerIdCodec.#BOTTOM_SIGNAL_LAYER_ID
|
|
41
|
+
}
|
|
42
|
+
if (saved >= 0x01010001 && saved <= 0x01010010) {
|
|
43
|
+
return (
|
|
44
|
+
PcbLayerIdCodec.#INTERNAL_PLANE_1_LAYER_ID +
|
|
45
|
+
(saved - 0x01010001)
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (saved >= 0x01020001 && saved <= 0x01020010) {
|
|
49
|
+
return PcbLayerIdCodec.#MECHANICAL_1_LAYER_ID + (saved - 0x01020001)
|
|
50
|
+
}
|
|
51
|
+
if ((saved & 0xffff0000) === PcbLayerIdCodec.#SYSTEM_LAYER_PREFIX) {
|
|
52
|
+
return PcbLayerIdCodec.#systemLayerIdFromPartition(saved & 0xffff)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the V7 saved-layer id for a known legacy layer id.
|
|
60
|
+
* @param {unknown} layerId
|
|
61
|
+
* @returns {number | null}
|
|
62
|
+
*/
|
|
63
|
+
static v7SaveIdFromLegacyLayerId(layerId) {
|
|
64
|
+
const legacy = Number(layerId)
|
|
65
|
+
if (!Number.isInteger(legacy) || legacy <= 0) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
legacy >= PcbLayerIdCodec.#TOP_SIGNAL_LAYER_ID &&
|
|
71
|
+
legacy < PcbLayerIdCodec.#BOTTOM_SIGNAL_LAYER_ID
|
|
72
|
+
) {
|
|
73
|
+
return PcbLayerIdCodec.#SIGNAL_LAYER_PREFIX + legacy
|
|
74
|
+
}
|
|
75
|
+
if (legacy === PcbLayerIdCodec.#BOTTOM_SIGNAL_LAYER_ID) {
|
|
76
|
+
return 0x0100ffff
|
|
77
|
+
}
|
|
78
|
+
if (legacy >= 39 && legacy <= 54) {
|
|
79
|
+
return PcbLayerIdCodec.#INTERNAL_PLANE_PREFIX + (legacy - 38)
|
|
80
|
+
}
|
|
81
|
+
if (legacy >= 57 && legacy <= 72) {
|
|
82
|
+
return PcbLayerIdCodec.#MECHANICAL_LAYER_PREFIX + (legacy - 56)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return PcbLayerIdCodec.#systemPartitionFromLayerId(legacy)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Converts one fixed V7 system-layer partition into a legacy layer id.
|
|
90
|
+
* @param {number} partition
|
|
91
|
+
* @returns {number | null}
|
|
92
|
+
*/
|
|
93
|
+
static #systemLayerIdFromPartition(partition) {
|
|
94
|
+
return (
|
|
95
|
+
{
|
|
96
|
+
6: 33,
|
|
97
|
+
7: 34,
|
|
98
|
+
8: 35,
|
|
99
|
+
9: 36,
|
|
100
|
+
10: 37,
|
|
101
|
+
11: 38,
|
|
102
|
+
12: 55,
|
|
103
|
+
13: 56,
|
|
104
|
+
14: 73,
|
|
105
|
+
15: 74,
|
|
106
|
+
16: 75
|
|
107
|
+
}[partition] || null
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Converts one fixed legacy system layer into a V7 saved-layer id.
|
|
113
|
+
* @param {number} layerId
|
|
114
|
+
* @returns {number | null}
|
|
115
|
+
*/
|
|
116
|
+
static #systemPartitionFromLayerId(layerId) {
|
|
117
|
+
const partition =
|
|
118
|
+
{
|
|
119
|
+
33: 6,
|
|
120
|
+
34: 7,
|
|
121
|
+
35: 8,
|
|
122
|
+
36: 9,
|
|
123
|
+
37: 10,
|
|
124
|
+
38: 11,
|
|
125
|
+
55: 12,
|
|
126
|
+
56: 13,
|
|
127
|
+
73: 14,
|
|
128
|
+
74: 15,
|
|
129
|
+
75: 16
|
|
130
|
+
}[layerId] || null
|
|
131
|
+
|
|
132
|
+
return partition === null
|
|
133
|
+
? null
|
|
134
|
+
: PcbLayerIdCodec.#SYSTEM_LAYER_PREFIX + partition
|
|
135
|
+
}
|
|
136
|
+
}
|