altium-toolkit 1.0.7 → 1.0.8

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.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
package/src/renderers.mjs CHANGED
@@ -6,6 +6,9 @@ export { BomTableRenderer } from './ui/BomTableRenderer.mjs'
6
6
  export { PcbArcUtils } from './ui/PcbArcUtils.mjs'
7
7
  export { PcbEdgeFacingGlyphNormalizer } from './ui/PcbEdgeFacingGlyphNormalizer.mjs'
8
8
  export { PcbFootprintPrimitiveSelector } from './ui/PcbFootprintPrimitiveSelector.mjs'
9
+ export { PcbInteractionIndex } from './ui/PcbInteractionIndex.mjs'
10
+ export { PcbInteractionItemRegistry } from './ui/PcbInteractionItemRegistry.mjs'
11
+ export { PcbInteractionLayerModel } from './ui/PcbInteractionLayerModel.mjs'
9
12
  export {
10
13
  PcbSideResolvedRenderModel,
11
14
  isCopperPrimitive,
@@ -137,6 +137,7 @@
137
137
  --pcb-copper-fill: rgba(196, 118, 70, 0.24);
138
138
  --pcb-copper-solid-fill: rgba(196, 118, 70, 0.78);
139
139
  --pcb-track-color: #c7522d;
140
+ --pcb-track-opacity: 1;
140
141
  --pcb-via-ring-fill: rgba(232, 236, 233, 0.92);
141
142
  --pcb-via-hole-fill: #0f746c;
142
143
  --pcb-footprint-fill: rgba(247, 230, 117, 0.14);
@@ -203,6 +204,11 @@
203
204
  stroke-linejoin: round;
204
205
  }
205
206
 
207
+ .pcb-track,
208
+ .pcb-arc {
209
+ opacity: var(--pcb-track-opacity, 1);
210
+ }
211
+
206
212
  .pcb-copper--surface .pcb-polygon {
207
213
  fill: var(--pcb-surface-copper-fill);
208
214
  }
@@ -0,0 +1,350 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Provides lightweight hit-test geometry helpers for PCB mil coordinates.
7
+ */
8
+ export class PcbInteractionGeometry {
9
+ /**
10
+ * Builds a circular geometry descriptor.
11
+ * @param {{ x?: unknown, y?: unknown }} center Center point.
12
+ * @param {unknown} radius Radius.
13
+ * @returns {object}
14
+ */
15
+ static circle(center, radius) {
16
+ return {
17
+ kind: 'circle',
18
+ center: PcbInteractionGeometry.point(center),
19
+ radius: Math.max(0, Number(radius) || 0)
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Builds a stroked segment geometry descriptor.
25
+ * @param {{ x?: unknown, y?: unknown }} start Start point.
26
+ * @param {{ x?: unknown, y?: unknown }} end End point.
27
+ * @param {unknown} radius Stroke radius.
28
+ * @returns {object}
29
+ */
30
+ static segment(start, end, radius = 0) {
31
+ return {
32
+ kind: 'segment',
33
+ start: PcbInteractionGeometry.point(start),
34
+ end: PcbInteractionGeometry.point(end),
35
+ radius: Math.max(0, Number(radius) || 0)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Builds a polygon geometry descriptor.
41
+ * @param {{ x?: unknown, y?: unknown }[]} points Polygon points.
42
+ * @returns {object}
43
+ */
44
+ static polygon(points) {
45
+ return {
46
+ kind: 'polygon',
47
+ points: (Array.isArray(points) ? points : []).map((point) =>
48
+ PcbInteractionGeometry.point(point)
49
+ )
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds an axis-aligned bounds geometry descriptor.
55
+ * @param {object} bounds Bounds.
56
+ * @returns {object}
57
+ */
58
+ static bounds(bounds) {
59
+ return {
60
+ kind: 'bounds',
61
+ bounds: PcbInteractionGeometry.normalizeBounds(bounds)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Builds a rotated rectangle polygon geometry descriptor.
67
+ * @param {object} rectangle Rectangle.
68
+ * @returns {object}
69
+ */
70
+ static rotatedRectangle(rectangle) {
71
+ const center = PcbInteractionGeometry.point(rectangle)
72
+ const width = Math.max(0, Number(rectangle?.width) || 0)
73
+ const height = Math.max(0, Number(rectangle?.height) || 0)
74
+ const rotation = (Number(rectangle?.rotation) || 0) * (Math.PI / 180)
75
+ const cos = Math.cos(rotation)
76
+ const sin = Math.sin(rotation)
77
+ const halfWidth = width / 2
78
+ const halfHeight = height / 2
79
+
80
+ return PcbInteractionGeometry.polygon(
81
+ [
82
+ { x: -halfWidth, y: -halfHeight },
83
+ { x: halfWidth, y: -halfHeight },
84
+ { x: halfWidth, y: halfHeight },
85
+ { x: -halfWidth, y: halfHeight }
86
+ ].map((point) => ({
87
+ x: center.x + point.x * cos - point.y * sin,
88
+ y: center.y + point.x * sin + point.y * cos
89
+ }))
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Normalizes a point value.
95
+ * @param {{ x?: unknown, y?: unknown }} point Point-like value.
96
+ * @returns {{ x: number, y: number }}
97
+ */
98
+ static point(point) {
99
+ return {
100
+ x: Number(point?.x) || 0,
101
+ y: Number(point?.y) || 0
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Returns bounds for a geometry descriptor.
107
+ * @param {object | null | undefined} geometry Geometry descriptor.
108
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
109
+ */
110
+ static boundsFor(geometry) {
111
+ if (!geometry || typeof geometry !== 'object') {
112
+ return PcbInteractionGeometry.normalizeBounds(null)
113
+ }
114
+
115
+ if (geometry.kind === 'bounds') {
116
+ return PcbInteractionGeometry.normalizeBounds(geometry.bounds)
117
+ }
118
+
119
+ const points = PcbInteractionGeometry.#pointsForBounds(geometry)
120
+ if (!points.length) return PcbInteractionGeometry.normalizeBounds(null)
121
+
122
+ const xs = points.map((point) => point.x)
123
+ const ys = points.map((point) => point.y)
124
+ const minX = Math.min(...xs)
125
+ const minY = Math.min(...ys)
126
+ const maxX = Math.max(...xs)
127
+ const maxY = Math.max(...ys)
128
+
129
+ return {
130
+ minX,
131
+ minY,
132
+ maxX,
133
+ maxY,
134
+ width: maxX - minX,
135
+ height: maxY - minY
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Normalizes an axis-aligned bounds value.
141
+ * @param {object | null | undefined} bounds Bounds-like value.
142
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
143
+ */
144
+ static normalizeBounds(bounds) {
145
+ const minX = Number(bounds?.minX) || 0
146
+ const minY = Number(bounds?.minY) || 0
147
+ const width = Number(bounds?.widthMil ?? bounds?.width) || 0
148
+ const height = Number(bounds?.heightMil ?? bounds?.height) || 0
149
+ const maxX = Number.isFinite(Number(bounds?.maxX))
150
+ ? Number(bounds.maxX)
151
+ : minX + width
152
+ const maxY = Number.isFinite(Number(bounds?.maxY))
153
+ ? Number(bounds.maxY)
154
+ : minY + height
155
+
156
+ return {
157
+ minX: Math.min(minX, maxX),
158
+ minY: Math.min(minY, maxY),
159
+ maxX: Math.max(minX, maxX),
160
+ maxY: Math.max(minY, maxY),
161
+ width: Math.abs(maxX - minX),
162
+ height: Math.abs(maxY - minY)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Returns whether a point intersects a geometry descriptor.
168
+ * @param {object | null | undefined} geometry Geometry descriptor.
169
+ * @param {{ x?: unknown, y?: unknown }} point Point-like value.
170
+ * @param {number} [tolerance] Extra tolerance.
171
+ * @returns {boolean}
172
+ */
173
+ static containsPoint(geometry, point, tolerance = 0) {
174
+ const normalizedPoint = PcbInteractionGeometry.point(point)
175
+ if (!geometry || typeof geometry !== 'object') return false
176
+
177
+ if (
178
+ !PcbInteractionGeometry.#boundsContainsPoint(
179
+ PcbInteractionGeometry.boundsFor(geometry),
180
+ normalizedPoint,
181
+ tolerance
182
+ )
183
+ ) {
184
+ return false
185
+ }
186
+
187
+ if (geometry.kind === 'circle') {
188
+ return (
189
+ PcbInteractionGeometry.#distance(
190
+ normalizedPoint,
191
+ geometry.center
192
+ ) <=
193
+ (Number(geometry.radius) || 0) + tolerance
194
+ )
195
+ }
196
+
197
+ if (geometry.kind === 'segment') {
198
+ return (
199
+ PcbInteractionGeometry.#pointToSegmentDistance(
200
+ normalizedPoint,
201
+ geometry.start,
202
+ geometry.end
203
+ ) <=
204
+ (Number(geometry.radius) || 0) + tolerance
205
+ )
206
+ }
207
+
208
+ if (geometry.kind === 'polygon') {
209
+ return PcbInteractionGeometry.#pointInPolygon(
210
+ normalizedPoint,
211
+ geometry.points || []
212
+ )
213
+ }
214
+
215
+ if (geometry.kind === 'bounds') {
216
+ return true
217
+ }
218
+
219
+ return false
220
+ }
221
+
222
+ /**
223
+ * Returns geometry points used for bounds calculations.
224
+ * @param {object} geometry Geometry descriptor.
225
+ * @returns {{ x: number, y: number }[]}
226
+ */
227
+ static #pointsForBounds(geometry) {
228
+ if (geometry.kind === 'circle') {
229
+ const center = PcbInteractionGeometry.point(geometry.center)
230
+ const radius = Math.max(0, Number(geometry.radius) || 0)
231
+ return [
232
+ { x: center.x - radius, y: center.y - radius },
233
+ { x: center.x + radius, y: center.y + radius }
234
+ ]
235
+ }
236
+
237
+ if (geometry.kind === 'segment') {
238
+ const start = PcbInteractionGeometry.point(geometry.start)
239
+ const end = PcbInteractionGeometry.point(geometry.end)
240
+ const radius = Math.max(0, Number(geometry.radius) || 0)
241
+ return [
242
+ {
243
+ x: Math.min(start.x, end.x) - radius,
244
+ y: Math.min(start.y, end.y) - radius
245
+ },
246
+ {
247
+ x: Math.max(start.x, end.x) + radius,
248
+ y: Math.max(start.y, end.y) + radius
249
+ }
250
+ ]
251
+ }
252
+
253
+ if (geometry.kind === 'polygon') {
254
+ return (Array.isArray(geometry.points) ? geometry.points : []).map(
255
+ (point) => PcbInteractionGeometry.point(point)
256
+ )
257
+ }
258
+
259
+ return []
260
+ }
261
+
262
+ /**
263
+ * Returns whether bounds contain a point.
264
+ * @param {object} bounds Bounds.
265
+ * @param {{ x: number, y: number }} point Point.
266
+ * @param {number} tolerance Tolerance.
267
+ * @returns {boolean}
268
+ */
269
+ static #boundsContainsPoint(bounds, point, tolerance) {
270
+ return (
271
+ point.x >= bounds.minX - tolerance &&
272
+ point.x <= bounds.maxX + tolerance &&
273
+ point.y >= bounds.minY - tolerance &&
274
+ point.y <= bounds.maxY + tolerance
275
+ )
276
+ }
277
+
278
+ /**
279
+ * Returns Euclidean distance between two points.
280
+ * @param {{ x?: unknown, y?: unknown }} first First point.
281
+ * @param {{ x?: unknown, y?: unknown }} second Second point.
282
+ * @returns {number}
283
+ */
284
+ static #distance(first, second) {
285
+ const a = PcbInteractionGeometry.point(first)
286
+ const b = PcbInteractionGeometry.point(second)
287
+ return Math.hypot(a.x - b.x, a.y - b.y)
288
+ }
289
+
290
+ /**
291
+ * Computes point-to-segment distance.
292
+ * @param {{ x: number, y: number }} point Point.
293
+ * @param {{ x?: unknown, y?: unknown }} start Segment start.
294
+ * @param {{ x?: unknown, y?: unknown }} end Segment end.
295
+ * @returns {number}
296
+ */
297
+ static #pointToSegmentDistance(point, start, end) {
298
+ const first = PcbInteractionGeometry.point(start)
299
+ const second = PcbInteractionGeometry.point(end)
300
+ const dx = second.x - first.x
301
+ const dy = second.y - first.y
302
+ const lengthSquared = dx * dx + dy * dy
303
+ if (lengthSquared === 0) {
304
+ return PcbInteractionGeometry.#distance(point, first)
305
+ }
306
+
307
+ const t = Math.max(
308
+ 0,
309
+ Math.min(
310
+ 1,
311
+ ((point.x - first.x) * dx + (point.y - first.y) * dy) /
312
+ lengthSquared
313
+ )
314
+ )
315
+ return PcbInteractionGeometry.#distance(point, {
316
+ x: first.x + t * dx,
317
+ y: first.y + t * dy
318
+ })
319
+ }
320
+
321
+ /**
322
+ * Returns whether a point is inside a polygon.
323
+ * @param {{ x: number, y: number }} point Point.
324
+ * @param {{ x?: unknown, y?: unknown }[]} polygon Polygon.
325
+ * @returns {boolean}
326
+ */
327
+ static #pointInPolygon(point, polygon) {
328
+ const points = (Array.isArray(polygon) ? polygon : []).map((entry) =>
329
+ PcbInteractionGeometry.point(entry)
330
+ )
331
+ let inside = false
332
+ for (
333
+ let index = 0, previous = points.length - 1;
334
+ index < points.length;
335
+ previous = index++
336
+ ) {
337
+ const current = points[index]
338
+ const before = points[previous]
339
+ const intersects =
340
+ current.y > point.y !== before.y > point.y &&
341
+ point.x <
342
+ ((before.x - current.x) * (point.y - current.y)) /
343
+ (before.y - current.y || Number.EPSILON) +
344
+ current.x
345
+ if (intersects) inside = !inside
346
+ }
347
+
348
+ return inside
349
+ }
350
+ }
@@ -0,0 +1,588 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbInteractionGeometry } from './PcbInteractionGeometry.mjs'
6
+ import { PcbInteractionItemRegistry } from './PcbInteractionItemRegistry.mjs'
7
+
8
+ const TYPE_PRIORITY = {
9
+ track: 100,
10
+ pad: 90,
11
+ via: 80,
12
+ component: 50,
13
+ text: 30,
14
+ zone: 10
15
+ }
16
+
17
+ const PLURAL_TYPE_KEYS = {
18
+ component: 'components',
19
+ pad: 'pads',
20
+ text: 'footprint-text',
21
+ track: 'tracks',
22
+ via: 'vias',
23
+ zone: 'zones'
24
+ }
25
+
26
+ /**
27
+ * Builds and queries selectable PCB items.
28
+ */
29
+ export class PcbInteractionIndex {
30
+ /**
31
+ * Builds all selectable items for a PCB document.
32
+ * @param {object} documentModel Toolkit document model.
33
+ * @returns {object[]}
34
+ */
35
+ static build(documentModel) {
36
+ const pcb = documentModel?.pcb || {}
37
+ const context = PcbInteractionIndex.#context(pcb)
38
+
39
+ return PcbInteractionIndex.#defaultRegistry()
40
+ .extract(documentModel, context)
41
+ .map((item, index) =>
42
+ PcbInteractionIndex.#normalizeItem(item, index)
43
+ )
44
+ .filter(Boolean)
45
+ }
46
+
47
+ /**
48
+ * Returns all hit candidates at the requested point.
49
+ * @param {object} documentModel Toolkit document model.
50
+ * @param {{ x?: unknown, y?: unknown }} point Hit-test point.
51
+ * @param {object} [options] Hit-test options.
52
+ * @returns {object[]}
53
+ */
54
+ static hitTest(documentModel, point, options = {}) {
55
+ return PcbInteractionIndex.build(documentModel)
56
+ .filter((item) =>
57
+ PcbInteractionIndex.#isVisibleCandidate(item, options)
58
+ )
59
+ .filter((item) =>
60
+ PcbInteractionGeometry.containsPoint(
61
+ item.geometry,
62
+ point,
63
+ Number(options?.tolerance) || 0
64
+ )
65
+ )
66
+ .sort(PcbInteractionIndex.#compareCandidates)
67
+ }
68
+
69
+ /**
70
+ * Picks the highest-priority candidate at the requested point.
71
+ * @param {object} documentModel Toolkit document model.
72
+ * @param {{ x?: unknown, y?: unknown }} point Hit-test point.
73
+ * @param {object} [options] Hit-test options.
74
+ * @returns {object | null}
75
+ */
76
+ static pick(documentModel, point, options = {}) {
77
+ return (
78
+ PcbInteractionIndex.hitTest(documentModel, point, options)[0] ||
79
+ null
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Creates extraction context for one PCB model.
85
+ * @param {object} pcb PCB model.
86
+ * @returns {object}
87
+ */
88
+ static #context(pcb) {
89
+ return {
90
+ components: Array.isArray(pcb.components) ? pcb.components : [],
91
+ layerNameFor: PcbInteractionIndex.#layerNameResolver(pcb)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Creates the default item extractor registry.
97
+ * @returns {PcbInteractionItemRegistry}
98
+ */
99
+ static #defaultRegistry() {
100
+ return PcbInteractionItemRegistry.create()
101
+ .register('zones', PcbInteractionIndex.#extractZones)
102
+ .register('tracks', PcbInteractionIndex.#extractTracks)
103
+ .register('pads', PcbInteractionIndex.#extractPads)
104
+ .register('vias', PcbInteractionIndex.#extractVias)
105
+ .register('components', PcbInteractionIndex.#extractComponents)
106
+ .register('footprint-text', PcbInteractionIndex.#extractTexts)
107
+ }
108
+
109
+ /**
110
+ * Extracts selectable copper zones.
111
+ * @param {object} documentModel Toolkit document model.
112
+ * @param {object} context Extraction context.
113
+ * @returns {object[]}
114
+ */
115
+ static #extractZones(documentModel, context) {
116
+ const pcb = documentModel?.pcb || {}
117
+ const zones = [
118
+ ...(Array.isArray(pcb.regions) ? pcb.regions : []),
119
+ ...(Array.isArray(pcb.shapeBasedRegions)
120
+ ? pcb.shapeBasedRegions
121
+ : []),
122
+ ...(Array.isArray(pcb.polygons) ? pcb.polygons : [])
123
+ ]
124
+
125
+ return zones
126
+ .map((zone, index) => {
127
+ const geometry = PcbInteractionIndex.#zoneGeometry(zone)
128
+ if (!geometry) return null
129
+
130
+ return {
131
+ id: PcbInteractionIndex.#itemId('zone', zone, index),
132
+ type: 'zone',
133
+ label: PcbInteractionIndex.#label('Zone', index),
134
+ layerKeys: PcbInteractionIndex.#layerKeys(zone, context),
135
+ netName: PcbInteractionIndex.#netName(zone),
136
+ side: PcbInteractionIndex.#sideForLayer(zone, context),
137
+ geometry,
138
+ source: zone
139
+ }
140
+ })
141
+ .filter(Boolean)
142
+ }
143
+
144
+ /**
145
+ * Extracts selectable tracks.
146
+ * @param {object} documentModel Toolkit document model.
147
+ * @param {object} context Extraction context.
148
+ * @returns {object[]}
149
+ */
150
+ static #extractTracks(documentModel, context) {
151
+ const tracks = Array.isArray(documentModel?.pcb?.tracks)
152
+ ? documentModel.pcb.tracks
153
+ : []
154
+
155
+ return tracks.map((track, index) => ({
156
+ id: PcbInteractionIndex.#itemId('track', track, index),
157
+ type: 'track',
158
+ label: PcbInteractionIndex.#label('Track', index),
159
+ layerKeys: PcbInteractionIndex.#layerKeys(track, context),
160
+ netName: PcbInteractionIndex.#netName(track),
161
+ side: PcbInteractionIndex.#sideForLayer(track, context),
162
+ geometry: PcbInteractionGeometry.segment(
163
+ { x: track.x1, y: track.y1 },
164
+ { x: track.x2, y: track.y2 },
165
+ Math.max(0.5, Number(track.width) / 2 || 0.5)
166
+ ),
167
+ source: track
168
+ }))
169
+ }
170
+
171
+ /**
172
+ * Extracts selectable pads.
173
+ * @param {object} documentModel Toolkit document model.
174
+ * @param {object} context Extraction context.
175
+ * @returns {object[]}
176
+ */
177
+ static #extractPads(documentModel, context) {
178
+ const pads = Array.isArray(documentModel?.pcb?.pads)
179
+ ? documentModel.pcb.pads
180
+ : []
181
+
182
+ return pads.map((pad, index) => {
183
+ const component = PcbInteractionIndex.#componentForPad(pad, context)
184
+
185
+ return {
186
+ id: PcbInteractionIndex.#itemId('pad', pad, index),
187
+ type: 'pad',
188
+ label: PcbInteractionIndex.#padLabel(pad, index),
189
+ layerKeys: PcbInteractionIndex.#layerKeys(pad, context),
190
+ netName: PcbInteractionIndex.#netName(pad),
191
+ componentKey: PcbInteractionIndex.#componentKey(component),
192
+ componentId: PcbInteractionIndex.#componentKey(component),
193
+ side: PcbInteractionIndex.#sideForLayer(pad, context),
194
+ geometry: PcbInteractionGeometry.rotatedRectangle({
195
+ x: pad.x,
196
+ y: pad.y,
197
+ width: PcbInteractionIndex.#padWidth(pad),
198
+ height: PcbInteractionIndex.#padHeight(pad),
199
+ rotation: pad.rotation
200
+ }),
201
+ source: pad
202
+ }
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Extracts selectable vias.
208
+ * @param {object} documentModel Toolkit document model.
209
+ * @returns {object[]}
210
+ */
211
+ static #extractVias(documentModel) {
212
+ const vias = Array.isArray(documentModel?.pcb?.vias)
213
+ ? documentModel.pcb.vias
214
+ : []
215
+
216
+ return vias.map((via, index) => ({
217
+ id: PcbInteractionIndex.#itemId('via', via, index),
218
+ type: 'via',
219
+ label: PcbInteractionIndex.#label('Via', index),
220
+ layerKeys: [],
221
+ netName: PcbInteractionIndex.#netName(via),
222
+ side: 'both',
223
+ geometry: PcbInteractionGeometry.circle(
224
+ { x: via.x, y: via.y },
225
+ Math.max(0.5, Number(via.diameter) / 2 || 0.5)
226
+ ),
227
+ source: via
228
+ }))
229
+ }
230
+
231
+ /**
232
+ * Extracts selectable component body estimates.
233
+ * @param {object} documentModel Toolkit document model.
234
+ * @returns {object[]}
235
+ */
236
+ static #extractComponents(documentModel) {
237
+ const components = Array.isArray(documentModel?.pcb?.components)
238
+ ? documentModel.pcb.components
239
+ : []
240
+
241
+ return components.map((component, index) => {
242
+ const size = PcbInteractionIndex.#componentSize(component)
243
+ return {
244
+ id: PcbInteractionIndex.#itemId('component', component, index),
245
+ type: 'component',
246
+ label: PcbInteractionIndex.#componentKey(component),
247
+ componentKey: PcbInteractionIndex.#componentKey(component),
248
+ componentId: PcbInteractionIndex.#componentKey(component),
249
+ layerKeys: [],
250
+ side:
251
+ String(component?.layer || '').toUpperCase() === 'BOTTOM'
252
+ ? 'back'
253
+ : 'front',
254
+ geometry: PcbInteractionGeometry.rotatedRectangle({
255
+ x: component.x,
256
+ y: component.y,
257
+ width: size.width,
258
+ height: size.height,
259
+ rotation: component.rotation
260
+ }),
261
+ source: component
262
+ }
263
+ })
264
+ }
265
+
266
+ /**
267
+ * Extracts selectable footprint text.
268
+ * @param {object} documentModel Toolkit document model.
269
+ * @param {object} context Extraction context.
270
+ * @returns {object[]}
271
+ */
272
+ static #extractTexts(documentModel, context) {
273
+ const texts = Array.isArray(documentModel?.pcb?.texts)
274
+ ? documentModel.pcb.texts
275
+ : []
276
+
277
+ return texts
278
+ .filter((text) => text?.visible !== false)
279
+ .map((text, index) => {
280
+ const component = PcbInteractionIndex.#componentForPad(
281
+ text,
282
+ context
283
+ )
284
+ const height = Math.max(1, Number(text?.height) || 1)
285
+ const width =
286
+ Math.max(1, String(text?.text || '').length) * height * 0.6
287
+
288
+ return {
289
+ id: PcbInteractionIndex.#itemId('text', text, index),
290
+ type: 'text',
291
+ label: String(text?.text || 'Text'),
292
+ layerKeys: PcbInteractionIndex.#layerKeys(text, context),
293
+ componentKey: PcbInteractionIndex.#componentKey(component),
294
+ componentId: PcbInteractionIndex.#componentKey(component),
295
+ side: PcbInteractionIndex.#sideForLayer(text, context),
296
+ geometry: PcbInteractionGeometry.rotatedRectangle({
297
+ x: text.x,
298
+ y: text.y,
299
+ width,
300
+ height,
301
+ rotation: text.rotation
302
+ }),
303
+ source: text
304
+ }
305
+ })
306
+ }
307
+
308
+ /**
309
+ * Builds geometry for a zone-like object.
310
+ * @param {object} zone Zone-like object.
311
+ * @returns {object | null}
312
+ */
313
+ static #zoneGeometry(zone) {
314
+ if (Array.isArray(zone?.points) && zone.points.length >= 3) {
315
+ return PcbInteractionGeometry.polygon(zone.points)
316
+ }
317
+ if (Array.isArray(zone?.segments) && zone.segments.length >= 3) {
318
+ return PcbInteractionGeometry.polygon(
319
+ zone.segments.map((segment) => ({
320
+ x: segment.x1,
321
+ y: segment.y1
322
+ }))
323
+ )
324
+ }
325
+ if (
326
+ Number.isFinite(Number(zone?.x1)) &&
327
+ Number.isFinite(Number(zone?.y1)) &&
328
+ Number.isFinite(Number(zone?.x2)) &&
329
+ Number.isFinite(Number(zone?.y2))
330
+ ) {
331
+ return PcbInteractionGeometry.bounds({
332
+ minX: Math.min(Number(zone.x1), Number(zone.x2)),
333
+ minY: Math.min(Number(zone.y1), Number(zone.y2)),
334
+ maxX: Math.max(Number(zone.x1), Number(zone.x2)),
335
+ maxY: Math.max(Number(zone.y1), Number(zone.y2))
336
+ })
337
+ }
338
+
339
+ return null
340
+ }
341
+
342
+ /**
343
+ * Normalizes item metadata.
344
+ * @param {object | null} item Extracted item.
345
+ * @param {number} index Stable item order.
346
+ * @returns {object | null}
347
+ */
348
+ static #normalizeItem(item, index) {
349
+ if (!item || typeof item !== 'object' || !item.geometry) return null
350
+
351
+ return {
352
+ priority: TYPE_PRIORITY[item.type] || 0,
353
+ order: index,
354
+ bounds: PcbInteractionGeometry.boundsFor(item.geometry),
355
+ ...item
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Returns whether an item is visible under hit-test filters.
361
+ * @param {object} item Interaction item.
362
+ * @param {object} options Hit-test options.
363
+ * @returns {boolean}
364
+ */
365
+ static #isVisibleCandidate(item, options) {
366
+ const side = PcbInteractionIndex.#normalizeSide(options?.side)
367
+ if (item.side !== 'both' && item.side !== side) return false
368
+
369
+ const hiddenObjects = new Set(
370
+ (Array.isArray(options?.hiddenObjects)
371
+ ? options.hiddenObjects
372
+ : []
373
+ ).map(String)
374
+ )
375
+ if (
376
+ hiddenObjects.has(item.objectKey) ||
377
+ hiddenObjects.has(PLURAL_TYPE_KEYS[item.type] || item.type)
378
+ ) {
379
+ return false
380
+ }
381
+
382
+ const hiddenLayers = new Set(
383
+ (Array.isArray(options?.hiddenLayers)
384
+ ? options.hiddenLayers
385
+ : []
386
+ ).map(String)
387
+ )
388
+ return (
389
+ !item.layerKeys?.length ||
390
+ item.layerKeys.some((layerKey) => !hiddenLayers.has(layerKey))
391
+ )
392
+ }
393
+
394
+ /**
395
+ * Compares candidates by priority and stable extraction order.
396
+ * @param {object} first First item.
397
+ * @param {object} second Second item.
398
+ * @returns {number}
399
+ */
400
+ static #compareCandidates(first, second) {
401
+ return second.priority - first.priority || first.order - second.order
402
+ }
403
+
404
+ /**
405
+ * Creates a layer-name resolver for layer-id based primitives.
406
+ * @param {object} pcb PCB model.
407
+ * @returns {(item: object) => string[]}
408
+ */
409
+ static #layerNameResolver(pcb) {
410
+ const byId = new Map()
411
+ const layers = [
412
+ ...(Array.isArray(pcb?.layers) ? pcb.layers : []),
413
+ ...(Array.isArray(pcb?.primitiveLayers) ? pcb.primitiveLayers : [])
414
+ ]
415
+
416
+ for (const layer of layers) {
417
+ const layerId = Number(layer?.layerId)
418
+ const name = String(layer?.name || '').trim()
419
+ if (Number.isInteger(layerId) && name) {
420
+ byId.set(layerId, name)
421
+ }
422
+ }
423
+
424
+ return (item) => {
425
+ const directLayer = String(item?.layer || '').trim()
426
+ if (directLayer) return [directLayer]
427
+
428
+ const layerId = Number(item?.layerId ?? item?.layerCode)
429
+ if (Number.isInteger(layerId) && byId.has(layerId)) {
430
+ return [byId.get(layerId)]
431
+ }
432
+
433
+ return []
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Resolves physical layer keys for an item.
439
+ * @param {object} item Source item.
440
+ * @param {object} context Extraction context.
441
+ * @returns {string[]}
442
+ */
443
+ static #layerKeys(item, context) {
444
+ return context.layerNameFor(item).filter(Boolean)
445
+ }
446
+
447
+ /**
448
+ * Resolves the board side from the primary layer.
449
+ * @param {object} item Source item.
450
+ * @param {object} context Extraction context.
451
+ * @returns {'front' | 'back' | 'both'}
452
+ */
453
+ static #sideForLayer(item, context) {
454
+ const layerKey = PcbInteractionIndex.#layerKeys(item, context)[0] || ''
455
+ const normalized = layerKey.toLowerCase()
456
+ if (normalized.includes('bottom')) return 'back'
457
+ if (normalized.includes('top')) return 'front'
458
+
459
+ return 'both'
460
+ }
461
+
462
+ /**
463
+ * Resolves a component for a pad-like primitive.
464
+ * @param {object} primitive Primitive.
465
+ * @param {object} context Extraction context.
466
+ * @returns {object | null}
467
+ */
468
+ static #componentForPad(primitive, context) {
469
+ const componentIndex = Number(primitive?.componentIndex)
470
+ if (!Number.isInteger(componentIndex)) return null
471
+
472
+ return (
473
+ context.components.find(
474
+ (component, index) =>
475
+ Number(component?.componentIndex) === componentIndex ||
476
+ index === componentIndex
477
+ ) || null
478
+ )
479
+ }
480
+
481
+ /**
482
+ * Resolves a stable component key.
483
+ * @param {object | null} component Component.
484
+ * @returns {string}
485
+ */
486
+ static #componentKey(component) {
487
+ return String(component?.designator || component?.id || '').trim()
488
+ }
489
+
490
+ /**
491
+ * Resolves a pad display label.
492
+ * @param {object} pad Pad.
493
+ * @param {number} index Pad index.
494
+ * @returns {string}
495
+ */
496
+ static #padLabel(pad, index) {
497
+ const name = String(pad?.name || pad?.number || '').trim()
498
+ return name || PcbInteractionIndex.#label('Pad', index)
499
+ }
500
+
501
+ /**
502
+ * Resolves a net name from common primitive fields.
503
+ * @param {object} item Primitive.
504
+ * @returns {string}
505
+ */
506
+ static #netName(item) {
507
+ return String(item?.netName || item?.net || '').trim()
508
+ }
509
+
510
+ /**
511
+ * Resolves a pad width.
512
+ * @param {object} pad Pad.
513
+ * @returns {number}
514
+ */
515
+ static #padWidth(pad) {
516
+ return Math.max(
517
+ 1,
518
+ Number(pad?.sizeTopX) ||
519
+ Number(pad?.sizeMidX) ||
520
+ Number(pad?.sizeBottomX) ||
521
+ Number(pad?.width) ||
522
+ 1
523
+ )
524
+ }
525
+
526
+ /**
527
+ * Resolves a pad height.
528
+ * @param {object} pad Pad.
529
+ * @returns {number}
530
+ */
531
+ static #padHeight(pad) {
532
+ return Math.max(
533
+ 1,
534
+ Number(pad?.sizeTopY) ||
535
+ Number(pad?.sizeMidY) ||
536
+ Number(pad?.sizeBottomY) ||
537
+ Number(pad?.height) ||
538
+ PcbInteractionIndex.#padWidth(pad)
539
+ )
540
+ }
541
+
542
+ /**
543
+ * Resolves a footprint body estimate for interaction.
544
+ * @param {object} component Component.
545
+ * @returns {{ width: number, height: number }}
546
+ */
547
+ static #componentSize(component) {
548
+ const pattern = String(component?.pattern || '').toUpperCase()
549
+ if (pattern.includes('QFN') || pattern.includes('QFP')) {
550
+ return { width: 180, height: 180 }
551
+ }
552
+ if (pattern.includes('SOT')) return { width: 140, height: 90 }
553
+ if (pattern.includes('0805')) return { width: 92, height: 48 }
554
+ if (pattern.includes('0603')) return { width: 72, height: 36 }
555
+ if (pattern.includes('0402')) return { width: 52, height: 28 }
556
+ return { width: 96, height: 60 }
557
+ }
558
+
559
+ /**
560
+ * Builds a stable fallback id.
561
+ * @param {string} type Item type.
562
+ * @param {object} source Source primitive.
563
+ * @param {number} index Item index.
564
+ * @returns {string}
565
+ */
566
+ static #itemId(type, source, index) {
567
+ return String(source?.id || source?.uuid || `${type}:${index}`)
568
+ }
569
+
570
+ /**
571
+ * Builds a fallback label.
572
+ * @param {string} base Label base.
573
+ * @param {number} index Item index.
574
+ * @returns {string}
575
+ */
576
+ static #label(base, index) {
577
+ return `${base} ${index + 1}`
578
+ }
579
+
580
+ /**
581
+ * Normalizes side input.
582
+ * @param {unknown} side Side input.
583
+ * @returns {'front' | 'back'}
584
+ */
585
+ static #normalizeSide(side) {
586
+ return side === 'bottom' || side === 'back' ? 'back' : 'front'
587
+ }
588
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Stores PCB interaction item extractors by rendered object group.
7
+ */
8
+ export class PcbInteractionItemRegistry {
9
+ /** @type {{ objectKey: string, extractor: (input: object, context: object) => object[] }[]} */
10
+ #entries
11
+
12
+ /**
13
+ * Creates an empty registry.
14
+ */
15
+ constructor() {
16
+ this.#entries = []
17
+ }
18
+
19
+ /**
20
+ * Creates an empty registry.
21
+ * @returns {PcbInteractionItemRegistry}
22
+ */
23
+ static create() {
24
+ return new PcbInteractionItemRegistry()
25
+ }
26
+
27
+ /**
28
+ * Adds an extractor for one object group.
29
+ * @param {string} objectKey Object visibility key.
30
+ * @param {(input: object, context: object) => object[]} extractor Extractor.
31
+ * @returns {PcbInteractionItemRegistry}
32
+ */
33
+ register(objectKey, extractor) {
34
+ if (typeof extractor !== 'function') return this
35
+ this.#entries.push({
36
+ objectKey: String(objectKey || ''),
37
+ extractor
38
+ })
39
+ return this
40
+ }
41
+
42
+ /**
43
+ * Extracts interaction items from all registered groups.
44
+ * @param {object} input Toolkit document model.
45
+ * @param {object} [context] Shared extraction context.
46
+ * @returns {object[]}
47
+ */
48
+ extract(input, context = {}) {
49
+ const items = []
50
+
51
+ for (const entry of this.#entries) {
52
+ const extracted = entry.extractor(input, {
53
+ ...context,
54
+ objectKey: entry.objectKey
55
+ })
56
+ for (const item of Array.isArray(extracted) ? extracted : []) {
57
+ items.push({
58
+ objectKey: entry.objectKey,
59
+ ...item
60
+ })
61
+ }
62
+ }
63
+
64
+ return items
65
+ }
66
+ }
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbInteractionIndex } from './PcbInteractionIndex.mjs'
6
+
7
+ const VIRTUAL_LAYER_DEFINITIONS = [
8
+ { key: 'tracks', label: 'Tracks' },
9
+ { key: 'vias', label: 'Vias' },
10
+ { key: 'pads', label: 'Pads' },
11
+ { key: 'holes', label: 'Holes' },
12
+ { key: 'zones', label: 'Zones' },
13
+ { key: 'footprint-text', label: 'Footprint text' }
14
+ ]
15
+
16
+ /**
17
+ * Builds a PCB layer summary with physical layers and virtual controls.
18
+ */
19
+ export class PcbInteractionLayerModel {
20
+ /**
21
+ * Resolves physical and virtual interaction layers.
22
+ * @param {object} documentModel Toolkit document model.
23
+ * @returns {{ physicalLayers: object[], virtualLayers: object[] }}
24
+ */
25
+ static resolve(documentModel) {
26
+ const pcb = documentModel?.pcb || {}
27
+ const physicalLayers = PcbInteractionLayerModel.#physicalLayers(pcb)
28
+ const items = PcbInteractionIndex.build(documentModel)
29
+ const layersByObject = PcbInteractionLayerModel.#layersByObject(items)
30
+
31
+ return {
32
+ physicalLayers,
33
+ virtualLayers: VIRTUAL_LAYER_DEFINITIONS.map((definition) => ({
34
+ ...definition,
35
+ physicalLayerKeys: Array.from(
36
+ layersByObject.get(definition.key) || []
37
+ )
38
+ }))
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves physical layer rows from board and primitive layer metadata.
44
+ * @param {object} pcb PCB model.
45
+ * @returns {object[]}
46
+ */
47
+ static #physicalLayers(pcb) {
48
+ const seen = new Set()
49
+ const layers = []
50
+ const sources = [
51
+ ...(Array.isArray(pcb?.layers) ? pcb.layers : []),
52
+ ...(Array.isArray(pcb?.primitiveLayers) ? pcb.primitiveLayers : [])
53
+ ]
54
+
55
+ for (const layer of sources) {
56
+ const key = String(layer?.name || layer?.layer || '').trim()
57
+ if (!key || seen.has(key)) continue
58
+ seen.add(key)
59
+ layers.push({
60
+ key,
61
+ label: key,
62
+ layerId: Number.isFinite(Number(layer?.layerId))
63
+ ? Number(layer.layerId)
64
+ : null
65
+ })
66
+ }
67
+
68
+ return layers
69
+ }
70
+
71
+ /**
72
+ * Collects referenced physical layer keys by virtual object key.
73
+ * @param {object[]} items Interaction items.
74
+ * @returns {Map<string, Set<string>>}
75
+ */
76
+ static #layersByObject(items) {
77
+ const layersByObject = new Map()
78
+
79
+ for (const item of items) {
80
+ if (!layersByObject.has(item.objectKey)) {
81
+ layersByObject.set(item.objectKey, new Set())
82
+ }
83
+ const layerSet = layersByObject.get(item.objectKey)
84
+ for (const layerKey of item.layerKeys || []) {
85
+ layerSet.add(layerKey)
86
+ }
87
+ if (item.type === 'pad' || item.type === 'via') {
88
+ if (!layersByObject.has('holes')) {
89
+ layersByObject.set('holes', new Set())
90
+ }
91
+ for (const layerKey of item.layerKeys || []) {
92
+ layersByObject.get('holes').add(layerKey)
93
+ }
94
+ }
95
+ }
96
+
97
+ return layersByObject
98
+ }
99
+ }
@@ -46,17 +46,82 @@ export class PcbScene3dBoardOutlineRefiner {
46
46
  return sceneDescription
47
47
  }
48
48
 
49
+ const refinedBoard = {
50
+ ...board,
51
+ minX: candidate.minX,
52
+ minY: candidate.minY,
53
+ widthMil: candidate.widthMil,
54
+ heightMil: candidate.heightMil,
55
+ centerX: candidate.minX + candidate.widthMil / 2,
56
+ centerY: candidate.minY + candidate.heightMil / 2,
57
+ segments: candidate.segments
58
+ }
59
+
49
60
  return {
50
61
  ...sceneDescription,
51
- board: {
52
- ...board,
53
- minX: candidate.minX,
54
- minY: candidate.minY,
55
- widthMil: candidate.widthMil,
56
- heightMil: candidate.heightMil,
57
- centerX: candidate.minX + candidate.widthMil / 2,
58
- centerY: candidate.minY + candidate.heightMil / 2,
59
- segments: candidate.segments
62
+ board: refinedBoard,
63
+ components: PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
64
+ sceneDescription?.components,
65
+ board,
66
+ refinedBoard
67
+ ),
68
+ externalPlacements:
69
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
70
+ sceneDescription?.externalPlacements,
71
+ board,
72
+ refinedBoard
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Realigns precomputed local placements to a refined board center.
79
+ * @param {object[] | undefined} placements Scene placements.
80
+ * @param {{ centerX?: number, centerY?: number }} previousBoard Previous board.
81
+ * @param {{ centerX?: number, centerY?: number }} refinedBoard Refined board.
82
+ * @returns {object[] | undefined}
83
+ */
84
+ static #realignLocalPlacements(placements, previousBoard, refinedBoard) {
85
+ if (!Array.isArray(placements)) {
86
+ return placements
87
+ }
88
+
89
+ const deltaX =
90
+ Number(previousBoard?.centerX || 0) -
91
+ Number(refinedBoard?.centerX || 0)
92
+ const deltaY =
93
+ Number(previousBoard?.centerY || 0) -
94
+ Number(refinedBoard?.centerY || 0)
95
+
96
+ if (!deltaX && !deltaY) {
97
+ return placements
98
+ }
99
+
100
+ return placements.map((placement) =>
101
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacement(
102
+ placement,
103
+ deltaX,
104
+ deltaY
105
+ )
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Applies one local origin delta to a scene placement.
111
+ * @param {object} placement Scene placement.
112
+ * @param {number} deltaX Local X delta.
113
+ * @param {number} deltaY Local Y delta.
114
+ * @returns {object}
115
+ */
116
+ static #realignLocalPlacement(placement, deltaX, deltaY) {
117
+ const positionMil = placement?.positionMil || {}
118
+
119
+ return {
120
+ ...placement,
121
+ positionMil: {
122
+ ...positionMil,
123
+ x: Number(positionMil.x || 0) + deltaX,
124
+ y: Number(positionMil.y || 0) + deltaY
60
125
  }
61
126
  }
62
127
  }
@@ -19,6 +19,9 @@ export class PcbScene3dBuilder {
19
19
  static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
20
20
  static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
21
21
  static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
22
+ static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
23
+ static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
24
+ static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
22
25
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
23
26
 
24
27
  /**
@@ -1159,16 +1162,41 @@ export class PcbScene3dBuilder {
1159
1162
  static #isBodyPositionNearBoard(componentBody, board) {
1160
1163
  const bodyX = Number(componentBody?.positionMil?.x || 0)
1161
1164
  const bodyY = Number(componentBody?.positionMil?.y || 0)
1162
- const minX = Number(board?.minX || 0) - 600
1163
- const minY = Number(board?.minY || 0) - 600
1165
+ const xOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1166
+ board?.widthMil
1167
+ )
1168
+ const yOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1169
+ board?.heightMil
1170
+ )
1171
+ const minX = Number(board?.minX || 0) - xOverhang
1172
+ const minY = Number(board?.minY || 0) - yOverhang
1164
1173
  const maxX =
1165
- Number(board?.minX || 0) + Number(board?.widthMil || 0) + 600
1174
+ Number(board?.minX || 0) + Number(board?.widthMil || 0) + xOverhang
1166
1175
  const maxY =
1167
- Number(board?.minY || 0) + Number(board?.heightMil || 0) + 600
1176
+ Number(board?.minY || 0) + Number(board?.heightMil || 0) + yOverhang
1168
1177
 
1169
1178
  return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
1170
1179
  }
1171
1180
 
1181
+ /**
1182
+ * Resolves a proportional unresolved-body margin for one board axis.
1183
+ * @param {number | string | undefined} spanMil Board axis span.
1184
+ * @returns {number}
1185
+ */
1186
+ static #resolveUnmatchedBodyOverhang(spanMil) {
1187
+ const proportional =
1188
+ Math.max(Number(spanMil || 0), 0) *
1189
+ PcbScene3dBuilder.#UNMATCHED_BODY_OVERHANG_RATIO
1190
+
1191
+ return Math.min(
1192
+ PcbScene3dBuilder.#UNMATCHED_BODY_MAX_OVERHANG_MIL,
1193
+ Math.max(
1194
+ proportional,
1195
+ PcbScene3dBuilder.#UNMATCHED_BODY_MIN_OVERHANG_MIL
1196
+ )
1197
+ )
1198
+ }
1199
+
1172
1200
  /**
1173
1201
  * Normalizes one angle into the range [0, 360).
1174
1202
  * @param {number} angle
@@ -341,7 +341,7 @@ export class PcbSvgRenderer {
341
341
  '"><path d="' +
342
342
  SchematicSvgUtils.escapeHtml(path) +
343
343
  '" /></clipPath></defs>' +
344
- '<path class="board-outline" d="' +
344
+ '<path class="board-outline pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" d="' +
345
345
  SchematicSvgUtils.escapeHtml(path) +
346
346
  '" />' +
347
347
  '<g class="pcb-copper-layers" clip-path="url(#' +
@@ -380,7 +380,7 @@ export class PcbSvgRenderer {
380
380
  '>' +
381
381
  textMarkup +
382
382
  '</g>' +
383
- '<path class="board-outline board-outline--stroke" d="' +
383
+ '<path class="board-outline board-outline--stroke pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" d="' +
384
384
  SchematicSvgUtils.escapeHtml(path) +
385
385
  '" />' +
386
386
  '</svg></div></section>'