altium-toolkit 1.1.39 → 1.1.41

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.39",
3
+ "version": "1.1.41",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -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 { PcbLayerIdCodec } from './PcbLayerIdCodec.mjs'
6
7
  import { SchematicTextParser } from './SchematicTextParser.mjs'
7
8
 
8
9
  const { getField, parseNumericField } = ParserUtils
@@ -114,14 +115,18 @@ export class AltiumLayoutParser {
114
115
  if (!match) continue
115
116
 
116
117
  const index = Number.parseInt(match[1], 10)
118
+ const layerId = parseNumericField(
119
+ fields,
120
+ 'V9_STACK_LAYER' + index + '_LAYERID'
121
+ )
122
+ const legacyLayerId =
123
+ PcbLayerIdCodec.legacyLayerIdFromV7SaveId(layerId) ?? undefined
117
124
  layers.push(
118
125
  AltiumLayoutParser.#stripUndefined({
119
126
  index,
120
127
  name: getField(fields, key),
121
- layerId: parseNumericField(
122
- fields,
123
- 'V9_STACK_LAYER' + index + '_LAYERID'
124
- ),
128
+ layerId,
129
+ legacyLayerId,
125
130
  kind: AltiumLayoutParser.#firstLayerStackTextField(
126
131
  fields,
127
132
  index,
@@ -180,6 +180,7 @@ export class PcbLayerStackReadModelBuilder {
180
180
  PcbLayerStackReadModelBuilder.#stripUndefined({
181
181
  index: layer.index,
182
182
  layerId: layer.layerId,
183
+ legacyLayerId: layer.legacyLayerId,
183
184
  layerKey: Number.isFinite(layer.layerId)
184
185
  ? 'L' + layer.layerId
185
186
  : undefined,
@@ -873,11 +873,18 @@ export class SchematicPinParser {
873
873
  ownerDrawnInternalPinOwners.has(ownerIndex) &&
874
874
  SchematicPinParser.#isCompactNumberedFetTerminalGroup(
875
875
  normalizedPins,
876
- semanticNames,
877
876
  orientationCount
878
877
  )
879
878
  ) {
880
879
  labelMode = 'number-only'
880
+ } else if (
881
+ ownerDrawnInternalPinOwners.has(ownerIndex) &&
882
+ SchematicPinParser.#isOwnerDrawnAmplifierTerminalGroup(
883
+ normalizedPins,
884
+ semanticNames
885
+ )
886
+ ) {
887
+ labelMode = 'number-only'
881
888
  } else if (
882
889
  SchematicPinParser.#isOwnerDrawnTerminalGlyphGroup(
883
890
  normalizedPins,
@@ -957,32 +964,94 @@ export class SchematicPinParser {
957
964
  }
958
965
 
959
966
  /**
960
- * Returns true when a compact owner-drawn FET body uses semantic terminal
961
- * names internally but still exposes external numeric contact labels.
967
+ * Returns true when a compact owner-drawn FET body or pin array uses
968
+ * semantic terminal names internally but still exposes external numeric
969
+ * contact labels.
962
970
  * @param {{ designator: string, name: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
963
- * @param {string[]} semanticNames
964
971
  * @param {number} orientationCount
965
972
  * @returns {boolean}
966
973
  */
967
- static #isCompactNumberedFetTerminalGroup(
968
- pins,
969
- semanticNames,
970
- orientationCount
971
- ) {
974
+ static #isCompactNumberedFetTerminalGroup(pins, orientationCount) {
972
975
  if (
973
- pins.length !== 4 ||
974
- orientationCount < 3 ||
976
+ pins.length < 4 ||
977
+ pins.length > 12 ||
978
+ orientationCount < 2 ||
979
+ !pins.every((pin) => /^\d+$/.test(String(pin.designator || '')))
980
+ ) {
981
+ return false
982
+ }
983
+
984
+ const normalizedNames = pins.map((pin) =>
985
+ String(pin.name || '')
986
+ .trim()
987
+ .toUpperCase()
988
+ )
989
+
990
+ return (
991
+ new Set(normalizedNames).size >= 2 &&
992
+ normalizedNames.every((name) =>
993
+ SchematicPinParser.#isFetTerminalName(name)
994
+ )
995
+ )
996
+ }
997
+
998
+ /**
999
+ * Returns true when an owner-drawn amplifier body already represents its
1000
+ * input, output, and supply terminal names in the drawn symbol artwork.
1001
+ * @param {{ designator: string, name: string }[]} pins
1002
+ * @param {string[]} semanticNames
1003
+ * @returns {boolean}
1004
+ */
1005
+ static #isOwnerDrawnAmplifierTerminalGroup(pins, semanticNames) {
1006
+ if (
1007
+ pins.length < 3 ||
1008
+ pins.length > 8 ||
975
1009
  semanticNames.length !== pins.length ||
976
1010
  !pins.every((pin) => /^\d+$/.test(String(pin.designator || '')))
977
1011
  ) {
978
1012
  return false
979
1013
  }
980
1014
 
981
- return semanticNames.every((name) =>
982
- SchematicPinParser.#isFetTerminalName(name)
1015
+ const normalizedNames = semanticNames.map((name) =>
1016
+ SchematicPinParser.#normalizeAmplifierTerminalName(name)
1017
+ )
1018
+
1019
+ return (
1020
+ normalizedNames.every(Boolean) &&
1021
+ normalizedNames.some((name) => name === 'IN+' || name === 'IN-') &&
1022
+ normalizedNames.includes('OUT')
983
1023
  )
984
1024
  }
985
1025
 
1026
+ /**
1027
+ * Normalizes amplifier terminal names that are normally drawn inside the
1028
+ * owner-authored symbol body.
1029
+ * @param {string} name Raw pin name.
1030
+ * @returns {string}
1031
+ */
1032
+ static #normalizeAmplifierTerminalName(name) {
1033
+ const normalized = String(name || '')
1034
+ .trim()
1035
+ .toUpperCase()
1036
+
1037
+ switch (normalized) {
1038
+ case 'IN+':
1039
+ case '+IN':
1040
+ case '+':
1041
+ return 'IN+'
1042
+ case 'IN-':
1043
+ case '-IN':
1044
+ case '-':
1045
+ return 'IN-'
1046
+ case 'OUT':
1047
+ case 'V+':
1048
+ case 'V-':
1049
+ return normalized
1050
+ default:
1051
+ return ''
1052
+ }
1053
+ }
1054
+
986
1055
  /**
987
1056
  * Returns true when a compact multi-side owner has transistor-like terminal
988
1057
  * letters that are part of the drawn symbol body, not external pin labels.
@@ -142,6 +142,16 @@
142
142
  --pcb-via-hole-fill: #0f746c;
143
143
  --pcb-footprint-fill: rgba(247, 230, 117, 0.14);
144
144
  --pcb-footprint-track-color: rgba(237, 172, 36, 1);
145
+ --pcb-detail-paste-fill: rgba(146, 146, 146, 0.94);
146
+ --pcb-detail-paste-stroke: rgba(154, 154, 154, 0.96);
147
+ --pcb-detail-mask-fill: rgba(137, 34, 132, 0.4);
148
+ --pcb-detail-mask-stroke: rgba(137, 34, 132, 0.82);
149
+ --pcb-detail-mechanical-fill: rgba(148, 160, 166, 0.32);
150
+ --pcb-detail-mechanical-stroke: rgba(96, 110, 118, 0.86);
151
+ --pcb-detail-drill-fill: rgba(79, 87, 93, 0.46);
152
+ --pcb-detail-drill-stroke: rgba(42, 50, 56, 0.88);
153
+ --pcb-detail-fill: rgba(150, 160, 166, 0.38);
154
+ --pcb-detail-stroke: rgba(80, 92, 100, 0.82);
145
155
  --pcb-dimension-color: rgba(39, 88, 111, 0.78);
146
156
  --pcb-dimension-text: #22343b;
147
157
  --pcb-text-knockout-fill: rgba(248, 246, 239, 0.96);
@@ -200,7 +210,9 @@
200
210
  .pcb-track,
201
211
  .pcb-arc,
202
212
  .pcb-footprint-track,
203
- .pcb-footprint-arc {
213
+ .pcb-footprint-arc,
214
+ .pcb-detail-track,
215
+ .pcb-detail-arc {
204
216
  fill: none;
205
217
  stroke-linecap: round;
206
218
  stroke-linejoin: round;
@@ -271,6 +283,60 @@
271
283
  stroke: var(--pcb-footprint-track-color);
272
284
  }
273
285
 
286
+ .pcb-detail-fill,
287
+ .pcb-detail-region {
288
+ fill: var(--pcb-detail-fill);
289
+ }
290
+
291
+ .pcb-detail-track,
292
+ .pcb-detail-arc {
293
+ stroke: var(--pcb-detail-stroke);
294
+ }
295
+
296
+ .pcb-detail-fill--paste,
297
+ .pcb-detail-region--paste {
298
+ fill: var(--pcb-detail-paste-fill);
299
+ }
300
+
301
+ .pcb-detail-track--paste,
302
+ .pcb-detail-arc--paste {
303
+ stroke: var(--pcb-detail-paste-stroke);
304
+ }
305
+
306
+ .pcb-detail-fill--mask,
307
+ .pcb-detail-region--mask,
308
+ .pcb-detail-fill--solder-mask,
309
+ .pcb-detail-region--solder-mask {
310
+ fill: var(--pcb-detail-mask-fill);
311
+ }
312
+
313
+ .pcb-detail-track--mask,
314
+ .pcb-detail-arc--mask,
315
+ .pcb-detail-track--solder-mask,
316
+ .pcb-detail-arc--solder-mask {
317
+ stroke: var(--pcb-detail-mask-stroke);
318
+ }
319
+
320
+ .pcb-detail-fill--mechanical,
321
+ .pcb-detail-region--mechanical {
322
+ fill: var(--pcb-detail-mechanical-fill);
323
+ }
324
+
325
+ .pcb-detail-track--mechanical,
326
+ .pcb-detail-arc--mechanical {
327
+ stroke: var(--pcb-detail-mechanical-stroke);
328
+ }
329
+
330
+ .pcb-detail-fill--drill,
331
+ .pcb-detail-region--drill {
332
+ fill: var(--pcb-detail-drill-fill);
333
+ }
334
+
335
+ .pcb-detail-track--drill,
336
+ .pcb-detail-arc--drill {
337
+ stroke: var(--pcb-detail-drill-stroke);
338
+ }
339
+
274
340
  .pcb-dimensions {
275
341
  pointer-events: none;
276
342
  }
@@ -56,13 +56,17 @@ export class PcbInteractionLayerModel {
56
56
  const key = String(layer?.name || layer?.layer || '').trim()
57
57
  if (!key || seen.has(key)) continue
58
58
  seen.add(key)
59
- layers.push({
59
+ const row = {
60
60
  key,
61
61
  label: key,
62
62
  layerId: Number.isFinite(Number(layer?.layerId))
63
63
  ? Number(layer.layerId)
64
64
  : null
65
- })
65
+ }
66
+ if (Number.isFinite(Number(layer?.legacyLayerId))) {
67
+ row.legacyLayerId = Number(layer.legacyLayerId)
68
+ }
69
+ layers.push(row)
66
70
  }
67
71
 
68
72
  return layers
@@ -0,0 +1,288 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+
7
+ /**
8
+ * Renders pad-derived paste and solder-mask apertures as independent SVG layers.
9
+ */
10
+ export class PcbPadMaskApertureRenderer {
11
+ static #PAD_SHAPE_RECTANGULAR = 2
12
+ static #SPECS = [
13
+ {
14
+ flag: 'hasTopPasteMaskOpening',
15
+ kind: 'paste',
16
+ primitiveKind: 'pad-paste',
17
+ side: 'top',
18
+ layerId: 35,
19
+ layerName: 'Top Paste',
20
+ expansionKey: 'effectivePasteMaskExpansion'
21
+ },
22
+ {
23
+ flag: 'hasBottomPasteMaskOpening',
24
+ kind: 'paste',
25
+ primitiveKind: 'pad-paste',
26
+ side: 'bottom',
27
+ layerId: 36,
28
+ layerName: 'Bottom Paste',
29
+ expansionKey: 'effectivePasteMaskExpansion'
30
+ },
31
+ {
32
+ flag: 'hasTopSolderMaskOpening',
33
+ kind: 'solder-mask',
34
+ primitiveKind: 'pad-solder-mask',
35
+ side: 'top',
36
+ layerId: 37,
37
+ layerName: 'Top Solder',
38
+ expansionKey: 'effectiveSolderMaskExpansion'
39
+ },
40
+ {
41
+ flag: 'hasBottomSolderMaskOpening',
42
+ kind: 'solder-mask',
43
+ primitiveKind: 'pad-solder-mask',
44
+ side: 'bottom',
45
+ layerId: 38,
46
+ layerName: 'Bottom Solder',
47
+ expansionKey: 'effectiveSolderMaskExpansion'
48
+ }
49
+ ]
50
+
51
+ /**
52
+ * Builds one SVG group containing all derived pad mask apertures.
53
+ * @param {object[]} pads Normalized pad records.
54
+ * @param {{ attributes?: (aperture: object) => string, padIndex?: (pad: object, index: number) => number }} [options]
55
+ * @returns {string}
56
+ */
57
+ static render(pads, options = {}) {
58
+ const apertures = []
59
+
60
+ for (const [fallbackIndex, pad] of (pads || []).entries()) {
61
+ const padIndex = PcbPadMaskApertureRenderer.#padIndex(
62
+ pad,
63
+ fallbackIndex,
64
+ options
65
+ )
66
+ for (const [specIndex, spec] of this.#SPECS.entries()) {
67
+ if (!pad?.[spec.flag]) continue
68
+ const aperture = this.#apertureForPad(
69
+ pad,
70
+ padIndex,
71
+ specIndex,
72
+ spec
73
+ )
74
+ if (!aperture) continue
75
+ apertures.push(
76
+ this.#renderAperture(aperture, options.attributes)
77
+ )
78
+ }
79
+ }
80
+
81
+ if (!apertures.length) return ''
82
+
83
+ return '<g class="pcb-pad-mask-layers">' + apertures.join('') + '</g>'
84
+ }
85
+
86
+ /**
87
+ * Resolves one stable pad index.
88
+ * @param {object} pad Pad record.
89
+ * @param {number} fallbackIndex Array index.
90
+ * @param {{ padIndex?: (pad: object, index: number) => number }} options
91
+ * @returns {number}
92
+ */
93
+ static #padIndex(pad, fallbackIndex, options) {
94
+ const resolved = options.padIndex?.(pad, fallbackIndex)
95
+ return Number.isInteger(resolved) ? resolved : fallbackIndex
96
+ }
97
+
98
+ /**
99
+ * Builds one normalized aperture descriptor for a pad/spec pair.
100
+ * @param {object} pad Pad record.
101
+ * @param {number} padIndex Stable pad index.
102
+ * @param {number} specIndex Spec index.
103
+ * @param {object} spec Aperture layer spec.
104
+ * @returns {object | null}
105
+ */
106
+ static #apertureForPad(pad, padIndex, specIndex, spec) {
107
+ const size = this.#sizeForPad(pad, spec)
108
+ if (size.width <= 0 || size.height <= 0) return null
109
+
110
+ return {
111
+ pad,
112
+ padIndex,
113
+ index: padIndex * this.#SPECS.length + specIndex,
114
+ spec,
115
+ size,
116
+ primitive: {
117
+ ...pad,
118
+ layerId: spec.layerId,
119
+ layerCode: spec.layerId,
120
+ legacyLayerId: spec.layerId,
121
+ layerName: spec.layerName
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolves aperture dimensions after the parsed mask expansion.
128
+ * @param {object} pad Pad record.
129
+ * @param {object} spec Aperture layer spec.
130
+ * @returns {{ width: number, height: number }}
131
+ */
132
+ static #sizeForPad(pad, spec) {
133
+ const side = spec.side === 'bottom' ? 'Bottom' : 'Top'
134
+ const width = Number(pad?.['size' + side + 'X'] || pad?.sizeMidX || 0)
135
+ const height = Number(pad?.['size' + side + 'Y'] || pad?.sizeMidY || 0)
136
+ const expansion = Number(pad?.[spec.expansionKey] || 0)
137
+
138
+ return {
139
+ width: Math.max(width + expansion * 2, 0),
140
+ height: Math.max(height + expansion * 2, 0)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Renders one aperture shape.
146
+ * @param {object} aperture Aperture descriptor.
147
+ * @param {(aperture: object) => string | undefined} attributes Attribute callback.
148
+ * @returns {string}
149
+ */
150
+ static #renderAperture(aperture, attributes) {
151
+ const className =
152
+ 'pcb-detail-fill pcb-detail-fill--' +
153
+ (aperture.spec.kind === 'solder-mask' ? 'mask' : 'paste') +
154
+ ' pcb-pad-mask-aperture pcb-pad-mask-aperture--' +
155
+ aperture.spec.kind
156
+ const renderedAttributes = attributes?.(aperture) || ''
157
+
158
+ return PcbPadMaskApertureRenderer.#isRoundAperture(aperture)
159
+ ? PcbPadMaskApertureRenderer.#renderCircle(
160
+ aperture,
161
+ className,
162
+ renderedAttributes
163
+ )
164
+ : PcbPadMaskApertureRenderer.#renderRect(
165
+ aperture,
166
+ className,
167
+ renderedAttributes
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Renders one circular aperture.
173
+ * @param {object} aperture Aperture descriptor.
174
+ * @param {string} className SVG class list.
175
+ * @param {string} attributes SVG attributes.
176
+ * @returns {string}
177
+ */
178
+ static #renderCircle(aperture, className, attributes) {
179
+ const { pad, size } = aperture
180
+ const radius = Math.max(size.width, size.height) / 2
181
+
182
+ return (
183
+ '<circle class="' +
184
+ className +
185
+ '" cx="' +
186
+ SchematicSvgUtils.formatNumber(Number(pad.x || 0)) +
187
+ '" cy="' +
188
+ SchematicSvgUtils.formatNumber(Number(pad.y || 0)) +
189
+ '" r="' +
190
+ SchematicSvgUtils.formatNumber(radius) +
191
+ '"' +
192
+ attributes +
193
+ ' />'
194
+ )
195
+ }
196
+
197
+ /**
198
+ * Renders one rectangular aperture.
199
+ * @param {object} aperture Aperture descriptor.
200
+ * @param {string} className SVG class list.
201
+ * @param {string} attributes SVG attributes.
202
+ * @returns {string}
203
+ */
204
+ static #renderRect(aperture, className, attributes) {
205
+ const { pad, size } = aperture
206
+ const x = Number(pad.x || 0) - size.width / 2
207
+ const y = Number(pad.y || 0) - size.height / 2
208
+
209
+ return (
210
+ '<rect class="' +
211
+ className +
212
+ '" x="' +
213
+ SchematicSvgUtils.formatNumber(x) +
214
+ '" y="' +
215
+ SchematicSvgUtils.formatNumber(y) +
216
+ '" width="' +
217
+ SchematicSvgUtils.formatNumber(size.width) +
218
+ '" height="' +
219
+ SchematicSvgUtils.formatNumber(size.height) +
220
+ '" rx="' +
221
+ SchematicSvgUtils.formatNumber(
222
+ PcbPadMaskApertureRenderer.#cornerRadius(aperture)
223
+ ) +
224
+ '" transform="rotate(' +
225
+ SchematicSvgUtils.formatNumber(Number(pad.rotation || 0)) +
226
+ ' ' +
227
+ SchematicSvgUtils.formatNumber(Number(pad.x || 0)) +
228
+ ' ' +
229
+ SchematicSvgUtils.formatNumber(Number(pad.y || 0)) +
230
+ ')"' +
231
+ attributes +
232
+ ' />'
233
+ )
234
+ }
235
+
236
+ /**
237
+ * Returns true when one aperture should be rendered as a circle.
238
+ * @param {object} aperture Aperture descriptor.
239
+ * @returns {boolean}
240
+ */
241
+ static #isRoundAperture(aperture) {
242
+ const shape = PcbPadMaskApertureRenderer.#shapeForAperture(aperture)
243
+ return (
244
+ shape !== PcbPadMaskApertureRenderer.#PAD_SHAPE_RECTANGULAR &&
245
+ Math.abs(aperture.size.width - aperture.size.height) < 0.001
246
+ )
247
+ }
248
+
249
+ /**
250
+ * Resolves the side-specific pad shape code.
251
+ * @param {object} aperture Aperture descriptor.
252
+ * @returns {number}
253
+ */
254
+ static #shapeForAperture(aperture) {
255
+ const side = aperture.spec.side === 'bottom' ? 'Bottom' : 'Top'
256
+ if (
257
+ side === 'Top' &&
258
+ aperture.pad?.hasRoundedRect &&
259
+ Number.isInteger(aperture.pad.roundedRectShapeTop)
260
+ ) {
261
+ return Number(aperture.pad.roundedRectShapeTop)
262
+ }
263
+ return Number(aperture.pad?.['shape' + side] || 0)
264
+ }
265
+
266
+ /**
267
+ * Resolves rectangle corner radius for rounded pad shapes.
268
+ * @param {object} aperture Aperture descriptor.
269
+ * @returns {number}
270
+ */
271
+ static #cornerRadius(aperture) {
272
+ if (
273
+ aperture.spec.side === 'top' &&
274
+ aperture.pad?.hasRoundedRect &&
275
+ Number.isFinite(aperture.pad.cornerRadiusTop)
276
+ ) {
277
+ return (
278
+ Math.min(aperture.size.width, aperture.size.height) *
279
+ (Number(aperture.pad.cornerRadiusTop) / 100)
280
+ )
281
+ }
282
+ if (PcbPadMaskApertureRenderer.#shapeForAperture(aperture) === 1) {
283
+ return Math.min(aperture.size.width, aperture.size.height) / 2
284
+ }
285
+
286
+ return 0
287
+ }
288
+ }