altium-toolkit 1.1.40 → 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.40",
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,
@@ -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
+ }
@@ -8,7 +8,10 @@ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs
8
8
  import { PcbEmbeddedFontFaceRenderer } from './PcbEmbeddedFontFaceRenderer.mjs'
9
9
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
10
10
  import { PcbCopperPrimitiveSplitter } from './PcbCopperPrimitiveSplitter.mjs'
11
+ import { PcbLayerIdCodec } from '../core/altium/PcbLayerIdCodec.mjs'
12
+ import { PcbLayerGroups } from '../core/altium/PcbLayerGroups.mjs'
11
13
  import { PcbNativeTextKnockoutDetector } from './PcbNativeTextKnockoutDetector.mjs'
14
+ import { PcbPadMaskApertureRenderer } from './PcbPadMaskApertureRenderer.mjs'
12
15
  import { PcbRegionPrimitiveRenderer } from './PcbRegionPrimitiveRenderer.mjs'
13
16
  import { PcbScene3dBoardOutlineRefiner } from './PcbScene3dBoardOutlineRefiner.mjs'
14
17
  import { PcbTextPrimitiveRenderer } from './PcbTextPrimitiveRenderer.mjs'
@@ -80,7 +83,7 @@ export class PcbSvgRenderer {
80
83
  arcs,
81
84
  renderedRegions
82
85
  )
83
- const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
86
+ const selectedFootprintPrimitives =
84
87
  PcbFootprintPrimitiveSelector.select(
85
88
  primitiveLayers,
86
89
  fills,
@@ -88,9 +91,21 @@ export class PcbSvgRenderer {
88
91
  arcs,
89
92
  renderedRegions,
90
93
  'top'
91
- ),
94
+ )
95
+ const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
96
+ selectedFootprintPrimitives,
92
97
  outline
93
98
  )
99
+ const detailPrimitives = PcbSvgRenderer.#detailPrimitiveCollections(
100
+ {
101
+ fills,
102
+ tracks,
103
+ arcs,
104
+ regions: renderedRegions
105
+ },
106
+ PcbSvgRenderer.#primitiveIdentitySet(selectedFootprintPrimitives),
107
+ semanticContext
108
+ )
94
109
  const texts = PcbTextPrimitiveRenderer.select(
95
110
  primitiveLayers,
96
111
  pcb.texts || [],
@@ -115,22 +130,26 @@ export class PcbSvgRenderer {
115
130
  [
116
131
  ...copperGroups.surface.fills,
117
132
  ...copperGroups.subsurface.fills,
118
- ...footprintPrimitives.fills
133
+ ...footprintPrimitives.fills,
134
+ ...detailPrimitives.fills
119
135
  ],
120
136
  [
121
137
  ...copperGroups.surface.tracks,
122
138
  ...copperGroups.subsurface.tracks,
123
- ...footprintPrimitives.tracks
139
+ ...footprintPrimitives.tracks,
140
+ ...detailPrimitives.tracks
124
141
  ],
125
142
  [
126
143
  ...copperGroups.surface.arcs,
127
144
  ...copperGroups.subsurface.arcs,
128
- ...footprintPrimitives.arcs
145
+ ...footprintPrimitives.arcs,
146
+ ...detailPrimitives.arcs
129
147
  ],
130
148
  [
131
149
  ...copperGroups.surface.regions,
132
150
  ...copperGroups.subsurface.regions,
133
- ...footprintPrimitives.regions
151
+ ...footprintPrimitives.regions,
152
+ ...detailPrimitives.regions
134
153
  ],
135
154
  vias,
136
155
  pads,
@@ -263,10 +282,27 @@ export class PcbSvgRenderer {
263
282
  )
264
283
  .join('')
265
284
  const regionMarkup = (regionList, visibilityClass) =>
266
- PcbRegionPrimitiveRenderer.buildMarkup(
267
- regionList,
268
- 'pcb-region pcb-region--' + visibilityClass
269
- )
285
+ regionList
286
+ .map((region, index) =>
287
+ PcbSvgRenderer.#appendSvgAttributes(
288
+ PcbRegionPrimitiveRenderer.buildMarkup(
289
+ [region],
290
+ 'pcb-region pcb-region--' + visibilityClass
291
+ ),
292
+ PcbSvgRenderer.#semanticAttributes(
293
+ 'region',
294
+ region,
295
+ PcbSvgRenderer.#primitiveIndex(
296
+ semanticContext,
297
+ 'regions',
298
+ region,
299
+ index
300
+ ),
301
+ semanticContext
302
+ )
303
+ )
304
+ )
305
+ .join('')
270
306
  const viaMarkup = vias
271
307
  .map((via, index) => {
272
308
  const ringRadius = Math.max((via.diameter || 0) / 2, 1)
@@ -328,8 +364,26 @@ export class PcbSvgRenderer {
328
364
  )
329
365
  )
330
366
  .join('')
367
+ const padMaskApertureMarkup = PcbPadMaskApertureRenderer.render(pads, {
368
+ padIndex: (pad, index) =>
369
+ PcbSvgRenderer.#primitiveIndex(
370
+ semanticContext,
371
+ 'pads',
372
+ pad,
373
+ index
374
+ ),
375
+ attributes: (aperture) =>
376
+ PcbSvgRenderer.#padMaskApertureAttributes(
377
+ aperture,
378
+ semanticContext
379
+ )
380
+ })
381
+ const detailMarkup = PcbSvgRenderer.#renderDetailPrimitives(
382
+ detailPrimitives,
383
+ semanticContext
384
+ )
331
385
  const footprintFillMarkup = footprintPrimitives.fills
332
- .map((fill) => {
386
+ .map((fill, index) => {
333
387
  const x = Math.min(fill.x1, fill.x2)
334
388
  const y = Math.min(fill.y1, fill.y2)
335
389
  const width = Math.abs(fill.x2 - fill.x1)
@@ -347,13 +401,25 @@ export class PcbSvgRenderer {
347
401
  SchematicSvgUtils.formatNumber(
348
402
  Math.min(width, height) / 6
349
403
  ) +
350
- '" />'
404
+ '"' +
405
+ PcbSvgRenderer.#semanticAttributes(
406
+ 'fill',
407
+ fill,
408
+ PcbSvgRenderer.#primitiveIndex(
409
+ semanticContext,
410
+ 'fills',
411
+ fill,
412
+ index
413
+ ),
414
+ semanticContext
415
+ ) +
416
+ ' />'
351
417
  )
352
418
  })
353
419
  .join('')
354
420
  const footprintTrackMarkup = footprintPrimitives.tracks
355
421
  .map(
356
- (track) =>
422
+ (track, index) =>
357
423
  '<line class="pcb-footprint-track" x1="' +
358
424
  SchematicSvgUtils.formatNumber(track.x1) +
359
425
  '" y1="' +
@@ -366,16 +432,60 @@ export class PcbSvgRenderer {
366
432
  SchematicSvgUtils.formatNumber(
367
433
  Math.max(track.width || 0, 1)
368
434
  ) +
369
- '" />'
435
+ '"' +
436
+ PcbSvgRenderer.#semanticAttributes(
437
+ 'track',
438
+ track,
439
+ PcbSvgRenderer.#primitiveIndex(
440
+ semanticContext,
441
+ 'tracks',
442
+ track,
443
+ index
444
+ ),
445
+ semanticContext
446
+ ) +
447
+ ' />'
370
448
  )
371
449
  .join('')
372
450
  const footprintArcMarkup = footprintPrimitives.arcs
373
- .map((arc) => PcbArcUtils.buildMarkup(arc, 'pcb-footprint-arc'))
451
+ .map((arc, index) =>
452
+ PcbSvgRenderer.#appendSvgAttributes(
453
+ PcbArcUtils.buildMarkup(arc, 'pcb-footprint-arc'),
454
+ PcbSvgRenderer.#semanticAttributes(
455
+ 'arc',
456
+ arc,
457
+ PcbSvgRenderer.#primitiveIndex(
458
+ semanticContext,
459
+ 'arcs',
460
+ arc,
461
+ index
462
+ ),
463
+ semanticContext
464
+ )
465
+ )
466
+ )
467
+ .join('')
468
+ const footprintRegionMarkup = footprintPrimitives.regions
469
+ .map((region, index) =>
470
+ PcbSvgRenderer.#appendSvgAttributes(
471
+ PcbRegionPrimitiveRenderer.buildMarkup(
472
+ [region],
473
+ 'pcb-footprint-region'
474
+ ),
475
+ PcbSvgRenderer.#semanticAttributes(
476
+ 'region',
477
+ region,
478
+ PcbSvgRenderer.#primitiveIndex(
479
+ semanticContext,
480
+ 'regions',
481
+ region,
482
+ index
483
+ ),
484
+ semanticContext
485
+ )
486
+ )
487
+ )
374
488
  .join('')
375
- const footprintRegionMarkup = PcbRegionPrimitiveRenderer.buildMarkup(
376
- footprintPrimitives.regions,
377
- 'pcb-footprint-region'
378
- )
379
489
  const textMarkup = PcbTextPrimitiveRenderer.render(texts, {
380
490
  semanticContext
381
491
  })
@@ -519,6 +629,7 @@ export class PcbSvgRenderer {
519
629
  SchematicSvgUtils.escapeHtml(path) +
520
630
  '"' +
521
631
  ' />' +
632
+ padMaskApertureMarkup +
522
633
  '<g class="pcb-copper-layers" clip-path="url(#' +
523
634
  clipPathId +
524
635
  ')">' +
@@ -545,6 +656,7 @@ export class PcbSvgRenderer {
545
656
  footprintArcMarkup +
546
657
  footprintRegionMarkup +
547
658
  '</g>' +
659
+ detailMarkup +
548
660
  '<g class="pcb-components">' +
549
661
  componentMarkup +
550
662
  '</g>' +
@@ -594,6 +706,316 @@ export class PcbSvgRenderer {
594
706
  )
595
707
  }
596
708
 
709
+ /**
710
+ * Selects non-copper layer primitives that need explicit SVG artwork.
711
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[], regions?: object[] }} primitives Primitive collections.
712
+ * @param {Set<object>} excludedPrimitives Primitives already rendered elsewhere.
713
+ * @param {object} semanticContext Semantic lookup context.
714
+ * @returns {{ fills: object[], tracks: object[], arcs: object[], regions: object[] }}
715
+ */
716
+ static #detailPrimitiveCollections(
717
+ primitives,
718
+ excludedPrimitives,
719
+ semanticContext
720
+ ) {
721
+ const includePrimitive = (primitive) =>
722
+ !excludedPrimitives.has(primitive) &&
723
+ PcbSvgRenderer.#isDetailPrimitive(primitive, semanticContext)
724
+
725
+ return {
726
+ fills: (primitives.fills || []).filter(includePrimitive),
727
+ tracks: (primitives.tracks || []).filter(includePrimitive),
728
+ arcs: (primitives.arcs || []).filter(includePrimitive),
729
+ regions: (primitives.regions || []).filter(includePrimitive)
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Builds an identity set for primitives rendered by a specialized path.
735
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[], regions?: object[] }} primitiveCollections Primitive collections.
736
+ * @returns {Set<object>}
737
+ */
738
+ static #primitiveIdentitySet(primitiveCollections) {
739
+ return new Set([
740
+ ...(primitiveCollections?.fills || []),
741
+ ...(primitiveCollections?.tracks || []),
742
+ ...(primitiveCollections?.arcs || []),
743
+ ...(primitiveCollections?.regions || [])
744
+ ])
745
+ }
746
+
747
+ /**
748
+ * Returns true when a primitive should render as generic layer detail.
749
+ * @param {object} primitive Primitive record.
750
+ * @param {object} semanticContext Semantic lookup context.
751
+ * @returns {boolean}
752
+ */
753
+ static #isDetailPrimitive(primitive, semanticContext) {
754
+ const layer = PcbSvgRenderer.#knownLayerForPrimitive(
755
+ primitive,
756
+ semanticContext
757
+ )
758
+ const role = PcbSvgRenderer.#layerRoleForDetail(layer)
759
+
760
+ return Boolean(layer) && !['copper', 'multi-layer'].includes(role)
761
+ }
762
+
763
+ /**
764
+ * Renders non-copper fabrication and documentation primitives.
765
+ * @param {{ fills: object[], tracks: object[], arcs: object[], regions: object[] }} primitives Primitive collections.
766
+ * @param {object} semanticContext Semantic lookup context.
767
+ * @returns {string}
768
+ */
769
+ static #renderDetailPrimitives(primitives, semanticContext) {
770
+ const fillMarkup = primitives.fills
771
+ .map((fill, index) =>
772
+ PcbSvgRenderer.#renderDetailFill(
773
+ fill,
774
+ PcbSvgRenderer.#primitiveIndex(
775
+ semanticContext,
776
+ 'fills',
777
+ fill,
778
+ index
779
+ ),
780
+ semanticContext
781
+ )
782
+ )
783
+ .join('')
784
+ const trackMarkup = primitives.tracks
785
+ .map((track, index) =>
786
+ PcbSvgRenderer.#renderDetailTrack(
787
+ track,
788
+ PcbSvgRenderer.#primitiveIndex(
789
+ semanticContext,
790
+ 'tracks',
791
+ track,
792
+ index
793
+ ),
794
+ semanticContext
795
+ )
796
+ )
797
+ .join('')
798
+ const arcMarkup = primitives.arcs
799
+ .map((arc, index) =>
800
+ PcbSvgRenderer.#renderDetailArc(
801
+ arc,
802
+ PcbSvgRenderer.#primitiveIndex(
803
+ semanticContext,
804
+ 'arcs',
805
+ arc,
806
+ index
807
+ ),
808
+ semanticContext
809
+ )
810
+ )
811
+ .join('')
812
+ const regionMarkup = primitives.regions
813
+ .map((region, index) =>
814
+ PcbSvgRenderer.#renderDetailRegion(
815
+ region,
816
+ PcbSvgRenderer.#primitiveIndex(
817
+ semanticContext,
818
+ 'regions',
819
+ region,
820
+ index
821
+ ),
822
+ semanticContext
823
+ )
824
+ )
825
+ .join('')
826
+
827
+ return fillMarkup || trackMarkup || arcMarkup || regionMarkup
828
+ ? '<g class="pcb-detail-layers">' +
829
+ fillMarkup +
830
+ trackMarkup +
831
+ arcMarkup +
832
+ regionMarkup +
833
+ '</g>'
834
+ : ''
835
+ }
836
+
837
+ /**
838
+ * Renders one non-copper fill primitive.
839
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} fill Fill primitive.
840
+ * @param {number} index Stable primitive index.
841
+ * @param {object} semanticContext Semantic lookup context.
842
+ * @returns {string}
843
+ */
844
+ static #renderDetailFill(fill, index, semanticContext) {
845
+ const x = Math.min(fill.x1, fill.x2)
846
+ const y = Math.min(fill.y1, fill.y2)
847
+ const width = Math.abs(fill.x2 - fill.x1)
848
+ const height = Math.abs(fill.y2 - fill.y1)
849
+ const className = PcbSvgRenderer.#detailPrimitiveClass(
850
+ 'fill',
851
+ fill,
852
+ semanticContext
853
+ )
854
+
855
+ return (
856
+ '<rect class="' +
857
+ className +
858
+ '" x="' +
859
+ SchematicSvgUtils.formatNumber(x) +
860
+ '" y="' +
861
+ SchematicSvgUtils.formatNumber(y) +
862
+ '" width="' +
863
+ SchematicSvgUtils.formatNumber(width) +
864
+ '" height="' +
865
+ SchematicSvgUtils.formatNumber(height) +
866
+ '" rx="' +
867
+ SchematicSvgUtils.formatNumber(Math.min(width, height) / 6) +
868
+ '"' +
869
+ PcbSvgRenderer.#semanticAttributes(
870
+ 'fill',
871
+ fill,
872
+ index,
873
+ semanticContext
874
+ ) +
875
+ ' />'
876
+ )
877
+ }
878
+
879
+ /**
880
+ * Renders one non-copper track primitive.
881
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }} track Track primitive.
882
+ * @param {number} index Stable primitive index.
883
+ * @param {object} semanticContext Semantic lookup context.
884
+ * @returns {string}
885
+ */
886
+ static #renderDetailTrack(track, index, semanticContext) {
887
+ const className = PcbSvgRenderer.#detailPrimitiveClass(
888
+ 'track',
889
+ track,
890
+ semanticContext
891
+ )
892
+
893
+ return (
894
+ '<line class="' +
895
+ className +
896
+ '" x1="' +
897
+ SchematicSvgUtils.formatNumber(track.x1) +
898
+ '" y1="' +
899
+ SchematicSvgUtils.formatNumber(track.y1) +
900
+ '" x2="' +
901
+ SchematicSvgUtils.formatNumber(track.x2) +
902
+ '" y2="' +
903
+ SchematicSvgUtils.formatNumber(track.y2) +
904
+ '" stroke-width="' +
905
+ SchematicSvgUtils.formatNumber(Math.max(track.width || 0, 1)) +
906
+ '"' +
907
+ PcbSvgRenderer.#semanticAttributes(
908
+ 'track',
909
+ track,
910
+ index,
911
+ semanticContext
912
+ ) +
913
+ ' />'
914
+ )
915
+ }
916
+
917
+ /**
918
+ * Renders one non-copper arc primitive.
919
+ * @param {object} arc Arc primitive.
920
+ * @param {number} index Stable primitive index.
921
+ * @param {object} semanticContext Semantic lookup context.
922
+ * @returns {string}
923
+ */
924
+ static #renderDetailArc(arc, index, semanticContext) {
925
+ return PcbSvgRenderer.#appendSvgAttributes(
926
+ PcbArcUtils.buildMarkup(
927
+ arc,
928
+ PcbSvgRenderer.#detailPrimitiveClass(
929
+ 'arc',
930
+ arc,
931
+ semanticContext
932
+ )
933
+ ),
934
+ PcbSvgRenderer.#semanticAttributes(
935
+ 'arc',
936
+ arc,
937
+ index,
938
+ semanticContext
939
+ )
940
+ )
941
+ }
942
+
943
+ /**
944
+ * Renders one non-copper region primitive.
945
+ * @param {object} region Region primitive.
946
+ * @param {number} index Stable primitive index.
947
+ * @param {object} semanticContext Semantic lookup context.
948
+ * @returns {string}
949
+ */
950
+ static #renderDetailRegion(region, index, semanticContext) {
951
+ return PcbSvgRenderer.#appendSvgAttributes(
952
+ PcbRegionPrimitiveRenderer.buildMarkup(
953
+ [region],
954
+ PcbSvgRenderer.#detailPrimitiveClass(
955
+ 'region',
956
+ region,
957
+ semanticContext
958
+ )
959
+ ),
960
+ PcbSvgRenderer.#semanticAttributes(
961
+ 'region',
962
+ region,
963
+ index,
964
+ semanticContext
965
+ )
966
+ )
967
+ }
968
+
969
+ /**
970
+ * Builds one detail primitive CSS class string.
971
+ * @param {string} primitiveKind Primitive kind.
972
+ * @param {object} primitive Primitive record.
973
+ * @param {object} semanticContext Semantic lookup context.
974
+ * @returns {string}
975
+ */
976
+ static #detailPrimitiveClass(primitiveKind, primitive, semanticContext) {
977
+ const layer = PcbSvgRenderer.#layerForPrimitive(
978
+ primitive,
979
+ semanticContext
980
+ )
981
+ const role = PcbSvgRenderer.#cssToken(
982
+ PcbSvgRenderer.#layerRoleForDetail(layer)
983
+ )
984
+
985
+ return (
986
+ 'pcb-detail-' +
987
+ primitiveKind +
988
+ ' pcb-detail-' +
989
+ primitiveKind +
990
+ '--' +
991
+ role
992
+ )
993
+ }
994
+
995
+ /**
996
+ * Resolves a layer role suitable for generic detail styling.
997
+ * @param {object | null} layer Layer descriptor.
998
+ * @returns {string}
999
+ */
1000
+ static #layerRoleForDetail(layer) {
1001
+ return String(layer?.role || 'other')
1002
+ }
1003
+
1004
+ /**
1005
+ * Converts arbitrary text to a safe CSS class token.
1006
+ * @param {unknown} value Raw token value.
1007
+ * @returns {string}
1008
+ */
1009
+ static #cssToken(value) {
1010
+ return (
1011
+ String(value || 'other')
1012
+ .trim()
1013
+ .toLowerCase()
1014
+ .replace(/[^a-z0-9]+/gu, '-')
1015
+ .replace(/^-+|-+$/gu, '') || 'other'
1016
+ )
1017
+ }
1018
+
597
1019
  /**
598
1020
  * Normalizes renderer view options.
599
1021
  * @param {{ viewKind?: string, layerView?: object } | undefined} options Render options.
@@ -721,17 +1143,31 @@ export class PcbSvgRenderer {
721
1143
  primitive?.layerId,
722
1144
  primitive?.layerCode
723
1145
  ])
724
- if (Number.isInteger(layerId) && Number.isInteger(layerView?.layerId)) {
725
- return layerId === layerView.layerId
1146
+ const layerIds = PcbSvgRenderer.#layerDescriptorIds(layerView)
1147
+ if (Number.isInteger(layerId) && layerIds.length) {
1148
+ return layerIds.includes(layerId)
726
1149
  }
727
1150
 
728
1151
  const primitiveName =
729
1152
  primitive?.layerName || primitive?.layer || primitive?.side || ''
730
1153
  if (primitiveName && layerView?.displayName) {
731
- return (
1154
+ if (
732
1155
  PcbSvgRenderer.#normalizeSemanticLookup(primitiveName) ===
733
1156
  PcbSvgRenderer.#normalizeSemanticLookup(layerView.displayName)
734
- )
1157
+ ) {
1158
+ return true
1159
+ }
1160
+
1161
+ const primitiveLayerId =
1162
+ PcbSvgRenderer.#legacyLayerIdForPrimitiveLayerName(
1163
+ primitiveName
1164
+ )
1165
+ if (
1166
+ Number.isInteger(primitiveLayerId) &&
1167
+ layerIds.includes(primitiveLayerId)
1168
+ ) {
1169
+ return true
1170
+ }
735
1171
  }
736
1172
 
737
1173
  return (
@@ -768,6 +1204,12 @@ export class PcbSvgRenderer {
768
1204
  ) {
769
1205
  layersById.set(descriptor.layerId, descriptor)
770
1206
  }
1207
+ if (
1208
+ Number.isInteger(descriptor.legacyLayerId) &&
1209
+ !layersById.has(descriptor.legacyLayerId)
1210
+ ) {
1211
+ layersById.set(descriptor.legacyLayerId, descriptor)
1212
+ }
771
1213
  const normalizedName = PcbSvgRenderer.#normalizeSemanticLookup(
772
1214
  descriptor.displayName
773
1215
  )
@@ -830,6 +1272,7 @@ export class PcbSvgRenderer {
830
1272
  fills: PcbSvgRenderer.#objectIndexMap(pcb?.fills || []),
831
1273
  tracks: PcbSvgRenderer.#objectIndexMap(pcb?.tracks || []),
832
1274
  arcs: PcbSvgRenderer.#objectIndexMap(pcb?.arcs || []),
1275
+ regions: PcbSvgRenderer.#objectIndexMap(pcb?.regions || []),
833
1276
  vias: PcbSvgRenderer.#objectIndexMap(pcb?.vias || []),
834
1277
  pads: PcbSvgRenderer.#objectIndexMap(pcb?.pads || []),
835
1278
  texts: PcbSvgRenderer.#objectIndexMap(pcb?.texts || []),
@@ -884,6 +1327,12 @@ export class PcbSvgRenderer {
884
1327
  pcb?.arcs || [],
885
1328
  semanticContext
886
1329
  ),
1330
+ ...PcbSvgRenderer.#semanticMetadataEntries(
1331
+ 'region',
1332
+ 'regions',
1333
+ pcb?.regions || [],
1334
+ semanticContext
1335
+ ),
887
1336
  ...PcbSvgRenderer.#semanticMetadataEntries(
888
1337
  'via',
889
1338
  'vias',
@@ -1370,6 +1819,7 @@ export class PcbSvgRenderer {
1370
1819
  layer.id,
1371
1820
  layer.index
1372
1821
  ])
1822
+ const legacyLayerId = PcbSvgRenderer.#legacyLayerId(layer, layerId)
1373
1823
  const displayName =
1374
1824
  layer.displayName || layer.name || layer.layerName || ''
1375
1825
  const layerKey = Number.isInteger(layerId)
@@ -1382,12 +1832,16 @@ export class PcbSvgRenderer {
1382
1832
 
1383
1833
  return PcbSvgRenderer.#stripEmptySemanticObject({
1384
1834
  layerId,
1835
+ legacyLayerId,
1385
1836
  layerKey,
1386
1837
  displayName: displayName || layerKey,
1387
1838
  role:
1388
1839
  layer.role ||
1389
1840
  layer.layerRole ||
1390
- PcbSvgRenderer.#inferLayerRole(displayName)
1841
+ PcbSvgRenderer.#inferLayerRole(
1842
+ displayName,
1843
+ legacyLayerId ?? layerId
1844
+ )
1391
1845
  })
1392
1846
  }
1393
1847
 
@@ -1398,10 +1852,50 @@ export class PcbSvgRenderer {
1398
1852
  */
1399
1853
  static #includedLayerIds(semanticContext) {
1400
1854
  return (semanticContext?.layerDescriptors || [])
1401
- .map((layer) => layer.layerId)
1855
+ .flatMap((layer) => PcbSvgRenderer.#layerDescriptorIds(layer))
1402
1856
  .filter((layerId) => Number.isInteger(layerId))
1403
1857
  }
1404
1858
 
1859
+ /**
1860
+ * Returns all numeric identifiers that can address one layer descriptor.
1861
+ * @param {object | null} layer Layer descriptor.
1862
+ * @returns {number[]}
1863
+ */
1864
+ static #layerDescriptorIds(layer) {
1865
+ return [
1866
+ PcbSvgRenderer.#firstFiniteNumber([layer?.layerId]),
1867
+ PcbSvgRenderer.#firstFiniteNumber([layer?.legacyLayerId])
1868
+ ].filter(
1869
+ (layerId, index, layerIds) =>
1870
+ Number.isInteger(layerId) && layerIds.indexOf(layerId) === index
1871
+ )
1872
+ }
1873
+
1874
+ /**
1875
+ * Resolves a legacy primitive-layer alias for a layer descriptor.
1876
+ * @param {object} layer Layer record.
1877
+ * @param {number | undefined} layerId Primary layer id.
1878
+ * @returns {number | undefined}
1879
+ */
1880
+ static #legacyLayerId(layer, layerId) {
1881
+ const explicitLegacyLayerId = PcbSvgRenderer.#firstFiniteNumber([
1882
+ layer.legacyLayerId,
1883
+ layer.legacyId
1884
+ ])
1885
+ const legacyLayerId = Number.isInteger(explicitLegacyLayerId)
1886
+ ? explicitLegacyLayerId
1887
+ : PcbLayerIdCodec.legacyLayerIdFromV7SaveId(layerId)
1888
+
1889
+ if (
1890
+ !Number.isInteger(legacyLayerId) ||
1891
+ (Number.isInteger(layerId) && legacyLayerId === layerId)
1892
+ ) {
1893
+ return undefined
1894
+ }
1895
+
1896
+ return legacyLayerId
1897
+ }
1898
+
1405
1899
  /**
1406
1900
  * Returns true when a PCB view contains only board outline metadata.
1407
1901
  * @param {object} pcb Normalized PCB model.
@@ -1426,9 +1920,15 @@ export class PcbSvgRenderer {
1426
1920
  /**
1427
1921
  * Infers one broad rendering role from a layer name.
1428
1922
  * @param {string} displayName Layer display name.
1923
+ * @param {number | undefined} layerId Legacy layer id.
1429
1924
  * @returns {string}
1430
1925
  */
1431
- static #inferLayerRole(displayName) {
1926
+ static #inferLayerRole(displayName, layerId) {
1927
+ const groupedRole = PcbSvgRenderer.#inferLayerRoleFromId(layerId)
1928
+ if (groupedRole) {
1929
+ return groupedRole
1930
+ }
1931
+
1432
1932
  const normalized = String(displayName || '').toLowerCase()
1433
1933
  if (/overlay|silk/u.test(normalized)) return 'overlay'
1434
1934
  if (/paste/u.test(normalized)) return 'paste'
@@ -1437,10 +1937,42 @@ export class PcbSvgRenderer {
1437
1937
  return 'mechanical'
1438
1938
  }
1439
1939
  if (/drill/u.test(normalized)) return 'drill'
1440
- if (/layer|copper|plane/u.test(normalized)) return 'copper'
1940
+ if (
1941
+ /layer|copper|plane/u.test(normalized) ||
1942
+ /\binternal[-_\s]*\d+\b/u.test(normalized) ||
1943
+ /\binner[-_\s]*\d+\b/u.test(normalized)
1944
+ ) {
1945
+ return 'copper'
1946
+ }
1441
1947
  return 'other'
1442
1948
  }
1443
1949
 
1950
+ /**
1951
+ * Infers one layer role from a legacy Altium layer id.
1952
+ * @param {unknown} layerId Legacy layer id.
1953
+ * @returns {string}
1954
+ */
1955
+ static #inferLayerRoleFromId(layerId) {
1956
+ const legacyLayerId = Number(layerId)
1957
+ if (!Number.isInteger(legacyLayerId)) {
1958
+ return ''
1959
+ }
1960
+ if (
1961
+ PcbLayerGroups.isCopper(legacyLayerId) ||
1962
+ PcbLayerGroups.isInternalPlane(legacyLayerId)
1963
+ ) {
1964
+ return 'copper'
1965
+ }
1966
+ if (PcbLayerGroups.isOverlay(legacyLayerId)) return 'overlay'
1967
+ if (PcbLayerGroups.isPaste(legacyLayerId)) return 'paste'
1968
+ if (PcbLayerGroups.isSolderMask(legacyLayerId)) return 'mask'
1969
+ if (PcbLayerGroups.isDrill(legacyLayerId)) return 'drill'
1970
+ if (PcbLayerGroups.isKeepout(legacyLayerId)) return 'keepout'
1971
+ if (PcbLayerGroups.isMechanical(legacyLayerId)) return 'mechanical'
1972
+ if (PcbLayerGroups.isMultiLayer(legacyLayerId)) return 'multi-layer'
1973
+ return ''
1974
+ }
1975
+
1444
1976
  /**
1445
1977
  * Resolves drill plating metadata for SVG and sidecar output.
1446
1978
  * @param {object} primitive Drill owner primitive.
@@ -1504,21 +2036,46 @@ export class PcbSvgRenderer {
1504
2036
  * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
1505
2037
  */
1506
2038
  static #layerForPrimitive(primitive, semanticContext) {
2039
+ const knownLayer = PcbSvgRenderer.#knownLayerForPrimitive(
2040
+ primitive,
2041
+ semanticContext
2042
+ )
2043
+ if (knownLayer) {
2044
+ return knownLayer
2045
+ }
2046
+
1507
2047
  const layerId = PcbSvgRenderer.#firstFiniteNumber([
1508
2048
  primitive?.layerId,
1509
2049
  primitive?.layerCode
1510
2050
  ])
1511
2051
  if (Number.isInteger(layerId)) {
1512
- return (
1513
- semanticContext.layersById.get(layerId) || {
1514
- layerId,
1515
- layerKey: 'L' + layerId,
1516
- displayName:
1517
- primitive?.layerName ||
1518
- primitive?.layer ||
1519
- 'Layer ' + layerId
1520
- }
1521
- )
2052
+ return {
2053
+ layerId,
2054
+ layerKey: 'L' + layerId,
2055
+ displayName:
2056
+ primitive?.layerName ||
2057
+ primitive?.layer ||
2058
+ 'Layer ' + layerId,
2059
+ role: PcbSvgRenderer.#inferLayerRole('', layerId)
2060
+ }
2061
+ }
2062
+
2063
+ return null
2064
+ }
2065
+
2066
+ /**
2067
+ * Resolves a layer descriptor only when the layer is in recovered metadata.
2068
+ * @param {object} primitive Primitive record.
2069
+ * @param {object} semanticContext Semantic lookup context.
2070
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
2071
+ */
2072
+ static #knownLayerForPrimitive(primitive, semanticContext) {
2073
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
2074
+ primitive?.layerId,
2075
+ primitive?.layerCode
2076
+ ])
2077
+ if (Number.isInteger(layerId)) {
2078
+ return semanticContext.layersById.get(layerId) || null
1522
2079
  }
1523
2080
 
1524
2081
  const layerName =
@@ -1527,7 +2084,63 @@ export class PcbSvgRenderer {
1527
2084
  PcbSvgRenderer.#normalizeSemanticLookup(layerName)
1528
2085
  )
1529
2086
 
1530
- return byName || null
2087
+ return (
2088
+ byName ||
2089
+ PcbSvgRenderer.#layerForPrimitiveLayerAlias(
2090
+ layerName,
2091
+ semanticContext
2092
+ )
2093
+ )
2094
+ }
2095
+
2096
+ /**
2097
+ * Resolves legacy Altium primitive layer names such as MID2 to a layer.
2098
+ * @param {string} layerName Primitive layer name.
2099
+ * @param {object} semanticContext Semantic lookup context.
2100
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
2101
+ */
2102
+ static #layerForPrimitiveLayerAlias(layerName, semanticContext) {
2103
+ const legacyLayerId =
2104
+ PcbSvgRenderer.#legacyLayerIdForPrimitiveLayerName(layerName)
2105
+
2106
+ return Number.isInteger(legacyLayerId)
2107
+ ? semanticContext.layersById.get(legacyLayerId) || null
2108
+ : null
2109
+ }
2110
+
2111
+ /**
2112
+ * Converts common primitive layer aliases into legacy layer ids.
2113
+ * @param {string} layerName Primitive layer name.
2114
+ * @returns {number | undefined}
2115
+ */
2116
+ static #legacyLayerIdForPrimitiveLayerName(layerName) {
2117
+ const compact = PcbSvgRenderer.#normalizeSemanticLookup(
2118
+ layerName
2119
+ ).replace(/[\s_-]+/gu, '')
2120
+
2121
+ if (compact === 'TOP' || compact === 'TOPLAYER') {
2122
+ return 1
2123
+ }
2124
+ if (
2125
+ compact === 'BOTTOM' ||
2126
+ compact === 'BOTTOMLAYER' ||
2127
+ compact === 'BOT' ||
2128
+ compact === 'BOTLAYER'
2129
+ ) {
2130
+ return 32
2131
+ }
2132
+
2133
+ const midLayerMatch = compact.match(/^MID(?:DLE)?(?:LAYER)?(\d+)$/u)
2134
+ if (midLayerMatch) {
2135
+ return Number(midLayerMatch[1]) + 1
2136
+ }
2137
+
2138
+ const internalLayerMatch = compact.match(/^(?:INTERNAL|INNER)(\d+)$/u)
2139
+ if (internalLayerMatch) {
2140
+ return Number(internalLayerMatch[1]) + 1
2141
+ }
2142
+
2143
+ return undefined
1531
2144
  }
1532
2145
 
1533
2146
  /**
@@ -1996,6 +2609,29 @@ export class PcbSvgRenderer {
1996
2609
  )
1997
2610
  }
1998
2611
 
2612
+ /**
2613
+ * Renders semantic attributes for a generated pad mask aperture.
2614
+ * @param {object} aperture Pad mask aperture descriptor.
2615
+ * @param {object} semanticContext Semantic lookup context.
2616
+ * @returns {string}
2617
+ */
2618
+ static #padMaskApertureAttributes(aperture, semanticContext) {
2619
+ return (
2620
+ PcbSvgRenderer.#semanticAttributes(
2621
+ aperture.spec.primitiveKind,
2622
+ aperture.primitive,
2623
+ aperture.index,
2624
+ semanticContext
2625
+ ) +
2626
+ PcbSvgRenderer.#renderDataAttributes({
2627
+ 'data-pad-number': PcbSvgRenderer.#padNumber(aperture.pad),
2628
+ 'data-mask-kind': aperture.spec.kind,
2629
+ 'data-mask-side': aperture.spec.side,
2630
+ 'data-source-pad-element-key': 'pcb-pad-' + aperture.padIndex
2631
+ })
2632
+ )
2633
+ }
2634
+
1999
2635
  /**
2000
2636
  * Renders one pad drill hole as SVG.
2001
2637
  * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null }} pad