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.
Files changed (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +6 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. 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
+ }