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 +1 -1
- package/src/renderers.mjs +3 -0
- package/src/styles/altium-renderers.css +6 -0
- package/src/ui/PcbInteractionGeometry.mjs +350 -0
- package/src/ui/PcbInteractionIndex.mjs +588 -0
- package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
- package/src/ui/PcbInteractionLayerModel.mjs +99 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
- package/src/ui/PcbScene3dBuilder.mjs +32 -4
- package/src/ui/PcbSvgRenderer.mjs +2 -2
package/package.json
CHANGED
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
1163
|
-
|
|
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) +
|
|
1174
|
+
Number(board?.minX || 0) + Number(board?.widthMil || 0) + xOverhang
|
|
1166
1175
|
const maxY =
|
|
1167
|
-
Number(board?.minY || 0) + Number(board?.heightMil || 0) +
|
|
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>'
|