altium-toolkit 0.1.18 → 0.1.21
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/core/altium/AltiumParser.mjs +15 -3
- package/src/core/altium/PcbFontMetricsParser.mjs +33 -6
- package/src/core/altium/PcbModelParser.mjs +82 -0
- package/src/core/altium/PcbTextPrimitiveParser.mjs +48 -3
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +401 -0
- package/src/core/altium/SchematicPinParser.mjs +136 -56
- package/src/core/altium/SchematicStreamExtractor.mjs +93 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +40 -12
- package/src/ui/PcbScene3dBuilder.mjs +593 -53
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +453 -0
- package/src/ui/SchematicImageRenderer.mjs +134 -32
- package/src/ui/SchematicJunctionRenderer.mjs +22 -4
- package/src/ui/SchematicNoteRenderer.mjs +73 -9
- package/src/ui/SchematicPinSvgRenderer.mjs +25 -3
- package/src/ui/SchematicPowerPortRenderer.mjs +183 -36
- package/src/ui/SchematicSvgRenderer.mjs +2 -2
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds overlay fill cutouts from drilled pad and via metadata.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dDrillCutoutBuilder {
|
|
9
|
+
static #CIRCLE_SEGMENTS = 24
|
|
10
|
+
static #SLOT_CAP_SEGMENTS = 12
|
|
11
|
+
static #EPSILON = 0.001
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Adds drill-shaped holes to every fill intersected by a pad or via drill.
|
|
15
|
+
* @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]} fills
|
|
16
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
|
|
17
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
|
|
18
|
+
* @returns {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]}
|
|
19
|
+
*/
|
|
20
|
+
static clipFills(fills, pads, vias) {
|
|
21
|
+
const cutouts = PcbScene3dDrillCutoutBuilder.#buildCutouts(pads, vias)
|
|
22
|
+
|
|
23
|
+
if (!cutouts.length) {
|
|
24
|
+
return fills
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return fills.map((fill) => {
|
|
28
|
+
const holes = cutouts
|
|
29
|
+
.filter((cutout) =>
|
|
30
|
+
PcbScene3dDrillCutoutBuilder.#cutoutTouchesFill(
|
|
31
|
+
cutout,
|
|
32
|
+
fill
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
.map((cutout) => cutout.points)
|
|
36
|
+
|
|
37
|
+
if (!holes.length) {
|
|
38
|
+
return fill
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...fill,
|
|
43
|
+
holes: [
|
|
44
|
+
...PcbScene3dDrillCutoutBuilder.#resolveExistingHoles(fill),
|
|
45
|
+
...holes
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds all known drill cutout contours.
|
|
53
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
|
|
54
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
|
|
55
|
+
* @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
|
|
56
|
+
*/
|
|
57
|
+
static #buildCutouts(pads, vias) {
|
|
58
|
+
return [
|
|
59
|
+
...PcbScene3dDrillCutoutBuilder.#buildPadCutouts(pads),
|
|
60
|
+
...PcbScene3dDrillCutoutBuilder.#buildViaCutouts(vias)
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds drill contours for drilled pads.
|
|
66
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
|
|
67
|
+
* @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
|
|
68
|
+
*/
|
|
69
|
+
static #buildPadCutouts(pads) {
|
|
70
|
+
return (Array.isArray(pads) ? pads : [])
|
|
71
|
+
.map((pad) => {
|
|
72
|
+
const x = Number(pad?.x)
|
|
73
|
+
const y = Number(pad?.y)
|
|
74
|
+
const diameter =
|
|
75
|
+
PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
|
|
76
|
+
'holeDiameter',
|
|
77
|
+
'drillDiameter'
|
|
78
|
+
])
|
|
79
|
+
const slotLength =
|
|
80
|
+
PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
|
|
81
|
+
'holeSlotLength',
|
|
82
|
+
'slotLength'
|
|
83
|
+
])
|
|
84
|
+
const rotationDeg =
|
|
85
|
+
Number(pad?.rotation || 0) + Number(pad?.holeRotation || 0)
|
|
86
|
+
|
|
87
|
+
return PcbScene3dDrillCutoutBuilder.#buildCutout(
|
|
88
|
+
x,
|
|
89
|
+
y,
|
|
90
|
+
diameter,
|
|
91
|
+
slotLength,
|
|
92
|
+
rotationDeg
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Builds circular drill contours for vias.
|
|
100
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
|
|
101
|
+
* @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
|
|
102
|
+
*/
|
|
103
|
+
static #buildViaCutouts(vias) {
|
|
104
|
+
return (Array.isArray(vias) ? vias : [])
|
|
105
|
+
.map((via) => {
|
|
106
|
+
const x = Number(via?.x)
|
|
107
|
+
const y = Number(via?.y)
|
|
108
|
+
const diameter =
|
|
109
|
+
PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(via, [
|
|
110
|
+
'holeDiameter',
|
|
111
|
+
'drillDiameter'
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
return PcbScene3dDrillCutoutBuilder.#buildCutout(
|
|
115
|
+
x,
|
|
116
|
+
y,
|
|
117
|
+
diameter,
|
|
118
|
+
0,
|
|
119
|
+
0
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Builds one drill contour from center, diameter, and optional slot length.
|
|
127
|
+
* @param {number} x
|
|
128
|
+
* @param {number} y
|
|
129
|
+
* @param {number} diameter
|
|
130
|
+
* @param {number} slotLength
|
|
131
|
+
* @param {number} rotationDeg
|
|
132
|
+
* @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] } | null}
|
|
133
|
+
*/
|
|
134
|
+
static #buildCutout(x, y, diameter, slotLength, rotationDeg) {
|
|
135
|
+
if (
|
|
136
|
+
!Number.isFinite(x) ||
|
|
137
|
+
!Number.isFinite(y) ||
|
|
138
|
+
!Number.isFinite(diameter) ||
|
|
139
|
+
diameter <= PcbScene3dDrillCutoutBuilder.#EPSILON
|
|
140
|
+
) {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const points =
|
|
145
|
+
Number.isFinite(slotLength) && slotLength > diameter
|
|
146
|
+
? PcbScene3dDrillCutoutBuilder.#buildSlotPoints(
|
|
147
|
+
x,
|
|
148
|
+
y,
|
|
149
|
+
diameter,
|
|
150
|
+
slotLength,
|
|
151
|
+
rotationDeg
|
|
152
|
+
)
|
|
153
|
+
: PcbScene3dDrillCutoutBuilder.#buildCirclePoints(
|
|
154
|
+
x,
|
|
155
|
+
y,
|
|
156
|
+
diameter
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
x,
|
|
161
|
+
y,
|
|
162
|
+
points,
|
|
163
|
+
bounds: PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Builds a polygonal circular drill contour.
|
|
169
|
+
* @param {number} x
|
|
170
|
+
* @param {number} y
|
|
171
|
+
* @param {number} diameter
|
|
172
|
+
* @returns {{ x: number, y: number }[]}
|
|
173
|
+
*/
|
|
174
|
+
static #buildCirclePoints(x, y, diameter) {
|
|
175
|
+
const radius = diameter / 2
|
|
176
|
+
|
|
177
|
+
return Array.from(
|
|
178
|
+
{ length: PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS },
|
|
179
|
+
(_, index) => {
|
|
180
|
+
const angle =
|
|
181
|
+
(Math.PI * 2 * index) /
|
|
182
|
+
PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
x: x + Math.cos(angle) * radius,
|
|
186
|
+
y: y + Math.sin(angle) * radius
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Builds a polygonal slotted drill contour.
|
|
194
|
+
* @param {number} x
|
|
195
|
+
* @param {number} y
|
|
196
|
+
* @param {number} diameter
|
|
197
|
+
* @param {number} slotLength
|
|
198
|
+
* @param {number} rotationDeg
|
|
199
|
+
* @returns {{ x: number, y: number }[]}
|
|
200
|
+
*/
|
|
201
|
+
static #buildSlotPoints(x, y, diameter, slotLength, rotationDeg) {
|
|
202
|
+
const radius = diameter / 2
|
|
203
|
+
const halfStraight = Math.max((slotLength - diameter) / 2, 0)
|
|
204
|
+
const rotation = (rotationDeg * Math.PI) / 180
|
|
205
|
+
const points = []
|
|
206
|
+
|
|
207
|
+
for (
|
|
208
|
+
let index = 0;
|
|
209
|
+
index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
|
|
210
|
+
index += 1
|
|
211
|
+
) {
|
|
212
|
+
const angle =
|
|
213
|
+
-Math.PI / 2 +
|
|
214
|
+
(Math.PI * index) /
|
|
215
|
+
PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
|
|
216
|
+
points.push(
|
|
217
|
+
PcbScene3dDrillCutoutBuilder.#rotatePoint(
|
|
218
|
+
x,
|
|
219
|
+
y,
|
|
220
|
+
halfStraight + Math.cos(angle) * radius,
|
|
221
|
+
Math.sin(angle) * radius,
|
|
222
|
+
rotation
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (
|
|
228
|
+
let index = 0;
|
|
229
|
+
index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
|
|
230
|
+
index += 1
|
|
231
|
+
) {
|
|
232
|
+
const angle =
|
|
233
|
+
Math.PI / 2 +
|
|
234
|
+
(Math.PI * index) /
|
|
235
|
+
PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
|
|
236
|
+
points.push(
|
|
237
|
+
PcbScene3dDrillCutoutBuilder.#rotatePoint(
|
|
238
|
+
x,
|
|
239
|
+
y,
|
|
240
|
+
-halfStraight + Math.cos(angle) * radius,
|
|
241
|
+
Math.sin(angle) * radius,
|
|
242
|
+
rotation
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return points
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Rotates one local point around a drill center.
|
|
252
|
+
* @param {number} centerX
|
|
253
|
+
* @param {number} centerY
|
|
254
|
+
* @param {number} localX
|
|
255
|
+
* @param {number} localY
|
|
256
|
+
* @param {number} rotation
|
|
257
|
+
* @returns {{ x: number, y: number }}
|
|
258
|
+
*/
|
|
259
|
+
static #rotatePoint(centerX, centerY, localX, localY, rotation) {
|
|
260
|
+
const cos = Math.cos(rotation)
|
|
261
|
+
const sin = Math.sin(rotation)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
x: centerX + localX * cos - localY * sin,
|
|
265
|
+
y: centerY + localX * sin + localY * cos
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Returns true when a drill contour should cut one fill.
|
|
271
|
+
* @param {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }} cutout
|
|
272
|
+
* @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
|
|
273
|
+
* @returns {boolean}
|
|
274
|
+
*/
|
|
275
|
+
static #cutoutTouchesFill(cutout, fill) {
|
|
276
|
+
const fillBounds = PcbScene3dDrillCutoutBuilder.#resolveFillBounds(fill)
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
!fillBounds ||
|
|
280
|
+
!PcbScene3dDrillCutoutBuilder.#boundsOverlap(
|
|
281
|
+
cutout.bounds,
|
|
282
|
+
fillBounds
|
|
283
|
+
)
|
|
284
|
+
) {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const fillPoints = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
|
|
289
|
+
if (fillPoints.length < 3) {
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
|
|
295
|
+
cutout,
|
|
296
|
+
fillPoints
|
|
297
|
+
) ||
|
|
298
|
+
cutout.points.some((point) =>
|
|
299
|
+
PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
|
|
300
|
+
point,
|
|
301
|
+
fillPoints
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolves existing authored holes from a fill.
|
|
309
|
+
* @param {{ holes?: { x: number, y: number }[][] }} fill
|
|
310
|
+
* @returns {{ x: number, y: number }[][]}
|
|
311
|
+
*/
|
|
312
|
+
static #resolveExistingHoles(fill) {
|
|
313
|
+
return Array.isArray(fill?.holes)
|
|
314
|
+
? fill.holes.filter((hole) => Array.isArray(hole))
|
|
315
|
+
: []
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolves finite polygon points from a fill.
|
|
320
|
+
* @param {{ points?: { x: number, y: number }[] }} fill
|
|
321
|
+
* @returns {{ x: number, y: number }[]}
|
|
322
|
+
*/
|
|
323
|
+
static #resolveFillPoints(fill) {
|
|
324
|
+
return (Array.isArray(fill?.points) ? fill.points : [])
|
|
325
|
+
.map((point) => ({
|
|
326
|
+
x: Number(point?.x),
|
|
327
|
+
y: Number(point?.y)
|
|
328
|
+
}))
|
|
329
|
+
.filter(
|
|
330
|
+
(point) => Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolves one fill's bounds.
|
|
336
|
+
* @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
|
|
337
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
338
|
+
*/
|
|
339
|
+
static #resolveFillBounds(fill) {
|
|
340
|
+
const points = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
|
|
341
|
+
|
|
342
|
+
if (points.length) {
|
|
343
|
+
return PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const x1 = Number(fill?.x1)
|
|
347
|
+
const y1 = Number(fill?.y1)
|
|
348
|
+
const x2 = Number(fill?.x2)
|
|
349
|
+
const y2 = Number(fill?.y2)
|
|
350
|
+
|
|
351
|
+
if (
|
|
352
|
+
!Number.isFinite(x1) ||
|
|
353
|
+
!Number.isFinite(y1) ||
|
|
354
|
+
!Number.isFinite(x2) ||
|
|
355
|
+
!Number.isFinite(y2)
|
|
356
|
+
) {
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
minX: Math.min(x1, x2),
|
|
362
|
+
minY: Math.min(y1, y2),
|
|
363
|
+
maxX: Math.max(x1, x2),
|
|
364
|
+
maxY: Math.max(y1, y2)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Resolves bounds for a point list.
|
|
370
|
+
* @param {{ x: number, y: number }[]} points
|
|
371
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
372
|
+
*/
|
|
373
|
+
static #resolvePointBounds(points) {
|
|
374
|
+
return points.reduce(
|
|
375
|
+
(bounds, point) => ({
|
|
376
|
+
minX: Math.min(bounds.minX, point.x),
|
|
377
|
+
minY: Math.min(bounds.minY, point.y),
|
|
378
|
+
maxX: Math.max(bounds.maxX, point.x),
|
|
379
|
+
maxY: Math.max(bounds.maxY, point.y)
|
|
380
|
+
}),
|
|
381
|
+
{
|
|
382
|
+
minX: Infinity,
|
|
383
|
+
minY: Infinity,
|
|
384
|
+
maxX: -Infinity,
|
|
385
|
+
maxY: -Infinity
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Returns true when two axis-aligned boxes overlap.
|
|
392
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} a
|
|
393
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} b
|
|
394
|
+
* @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
static #boundsOverlap(a, b) {
|
|
397
|
+
return (
|
|
398
|
+
a.minX <= b.maxX &&
|
|
399
|
+
a.maxX >= b.minX &&
|
|
400
|
+
a.minY <= b.maxY &&
|
|
401
|
+
a.maxY >= b.minY
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Returns true when a point lies inside a polygon.
|
|
407
|
+
* @param {{ x: number, y: number }} point
|
|
408
|
+
* @param {{ x: number, y: number }[]} polygon
|
|
409
|
+
* @returns {boolean}
|
|
410
|
+
*/
|
|
411
|
+
static #isPointInPolygon(point, polygon) {
|
|
412
|
+
let inside = false
|
|
413
|
+
|
|
414
|
+
for (
|
|
415
|
+
let index = 0, previousIndex = polygon.length - 1;
|
|
416
|
+
index < polygon.length;
|
|
417
|
+
previousIndex = index, index += 1
|
|
418
|
+
) {
|
|
419
|
+
const current = polygon[index]
|
|
420
|
+
const previous = polygon[previousIndex]
|
|
421
|
+
const intersects =
|
|
422
|
+
current.y > point.y !== previous.y > point.y &&
|
|
423
|
+
point.x <
|
|
424
|
+
((previous.x - current.x) * (point.y - current.y)) /
|
|
425
|
+
(previous.y - current.y) +
|
|
426
|
+
current.x
|
|
427
|
+
|
|
428
|
+
if (intersects) {
|
|
429
|
+
inside = !inside
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return inside
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Resolves the first positive numeric field from an object.
|
|
438
|
+
* @param {Record<string, unknown>} source
|
|
439
|
+
* @param {string[]} keys
|
|
440
|
+
* @returns {number}
|
|
441
|
+
*/
|
|
442
|
+
static #resolvePositiveNumber(source, keys) {
|
|
443
|
+
for (const key of keys) {
|
|
444
|
+
const value = Number(source?.[key])
|
|
445
|
+
|
|
446
|
+
if (Number.isFinite(value) && value > 0) {
|
|
447
|
+
return value
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return 0
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -6,6 +6,7 @@ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
|
|
|
6
6
|
import { SchematicColorResolver } from './SchematicColorResolver.mjs'
|
|
7
7
|
|
|
8
8
|
const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
|
|
9
|
+
const MISSING_IMAGE_WRAP_SAFETY_FACTOR = 0.96
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Renders normalized schematic image placements.
|
|
@@ -13,7 +14,7 @@ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
|
|
|
13
14
|
export class SchematicImageRenderer {
|
|
14
15
|
/**
|
|
15
16
|
* Builds markup for embedded schematic images and unresolved placeholders.
|
|
16
|
-
* @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
|
|
17
|
+
* @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
|
|
17
18
|
* @param {number} sheetHeight
|
|
18
19
|
* @returns {string}
|
|
19
20
|
*/
|
|
@@ -63,20 +64,16 @@ export class SchematicImageRenderer {
|
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Builds one placeholder frame when an image payload is unavailable.
|
|
66
|
-
* @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
|
|
67
|
+
* @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string }} image
|
|
67
68
|
* @param {number} sheetHeight
|
|
68
69
|
* @returns {string}
|
|
69
70
|
*/
|
|
70
71
|
static #buildPlaceholderMarkup(image, sheetHeight) {
|
|
71
72
|
const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
|
|
72
|
-
const stroke = SchematicColorResolver.resolveColor(
|
|
73
|
-
'#c0c0c0',
|
|
74
|
-
'--schematic-note-border-color'
|
|
75
|
-
)
|
|
76
73
|
|
|
77
74
|
return (
|
|
78
75
|
'<g class="schematic-image-placeholder">' +
|
|
79
|
-
'<
|
|
76
|
+
'<svg x="' +
|
|
80
77
|
formatNumber(bounds.x) +
|
|
81
78
|
'" y="' +
|
|
82
79
|
formatNumber(bounds.y) +
|
|
@@ -84,35 +81,140 @@ export class SchematicImageRenderer {
|
|
|
84
81
|
formatNumber(bounds.width) +
|
|
85
82
|
'" height="' +
|
|
86
83
|
formatNumber(bounds.height) +
|
|
87
|
-
'"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
93
|
-
formatNumber(bounds.y) +
|
|
94
|
-
'" x2="' +
|
|
95
|
-
formatNumber(bounds.x + bounds.width) +
|
|
96
|
-
'" y2="' +
|
|
97
|
-
formatNumber(bounds.y + bounds.height) +
|
|
98
|
-
'" stroke="' +
|
|
99
|
-
escapeHtml(stroke) +
|
|
100
|
-
'" stroke-width="1" />' +
|
|
101
|
-
'<line x1="' +
|
|
102
|
-
formatNumber(bounds.x) +
|
|
103
|
-
'" y1="' +
|
|
104
|
-
formatNumber(bounds.y + bounds.height) +
|
|
105
|
-
'" x2="' +
|
|
106
|
-
formatNumber(bounds.x + bounds.width) +
|
|
107
|
-
'" y2="' +
|
|
108
|
-
formatNumber(bounds.y) +
|
|
109
|
-
'" stroke="' +
|
|
110
|
-
escapeHtml(stroke) +
|
|
111
|
-
'" stroke-width="1" />' +
|
|
84
|
+
'" overflow="hidden">' +
|
|
85
|
+
SchematicImageRenderer.#buildMissingImageMessageMarkup(
|
|
86
|
+
image,
|
|
87
|
+
bounds
|
|
88
|
+
) +
|
|
89
|
+
'</svg>' +
|
|
112
90
|
'</g>'
|
|
113
91
|
)
|
|
114
92
|
}
|
|
115
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Builds the visible message shown by Altium for unavailable image files.
|
|
96
|
+
* @param {{ fileName?: string }} image
|
|
97
|
+
* @param {{ x: number, y: number, width: number, height: number }} bounds
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
static #buildMissingImageMessageMarkup(image, bounds) {
|
|
101
|
+
const padding = Math.min(6, Math.max(bounds.width * 0.08, 2))
|
|
102
|
+
const fontSize = Math.min(8, Math.max(bounds.height / 18, 5))
|
|
103
|
+
const lineHeight = fontSize * 1.18
|
|
104
|
+
const usableWidth = Math.max(bounds.width - padding * 2, 1)
|
|
105
|
+
const lines = SchematicImageRenderer.#buildMissingImageMessageLines(
|
|
106
|
+
image.fileName,
|
|
107
|
+
usableWidth * MISSING_IMAGE_WRAP_SAFETY_FACTOR,
|
|
108
|
+
fontSize
|
|
109
|
+
)
|
|
110
|
+
const textColor = SchematicColorResolver.resolveColor(
|
|
111
|
+
'#2c3134',
|
|
112
|
+
'--schematic-text-color'
|
|
113
|
+
)
|
|
114
|
+
const startX = padding
|
|
115
|
+
const startY = padding + fontSize
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
'<text class="schematic-image-placeholder-message" x="' +
|
|
119
|
+
formatNumber(startX) +
|
|
120
|
+
'" y="' +
|
|
121
|
+
formatNumber(startY) +
|
|
122
|
+
'" fill="' +
|
|
123
|
+
escapeHtml(textColor) +
|
|
124
|
+
'" font-family="Times New Roman" font-size="' +
|
|
125
|
+
formatNumber(fontSize) +
|
|
126
|
+
'">' +
|
|
127
|
+
lines
|
|
128
|
+
.map(
|
|
129
|
+
(line, index) =>
|
|
130
|
+
'<tspan x="' +
|
|
131
|
+
formatNumber(startX) +
|
|
132
|
+
'" dy="' +
|
|
133
|
+
formatNumber(index === 0 ? 0 : lineHeight) +
|
|
134
|
+
'">' +
|
|
135
|
+
escapeHtml(line) +
|
|
136
|
+
'</tspan>'
|
|
137
|
+
)
|
|
138
|
+
.join('') +
|
|
139
|
+
'</text>'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wraps one missing-image message to the image placeholder width.
|
|
145
|
+
* @param {string | undefined} fileName
|
|
146
|
+
* @param {number} width
|
|
147
|
+
* @param {number} fontSize
|
|
148
|
+
* @returns {string[]}
|
|
149
|
+
*/
|
|
150
|
+
static #buildMissingImageMessageLines(fileName, width, fontSize) {
|
|
151
|
+
return [
|
|
152
|
+
'Cannot open file',
|
|
153
|
+
...SchematicImageRenderer.#wrapMissingImageFileName(
|
|
154
|
+
fileName || 'image file',
|
|
155
|
+
width,
|
|
156
|
+
fontSize
|
|
157
|
+
),
|
|
158
|
+
'. File does not exist.'
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Wraps file names using conservative estimated rendered glyph widths.
|
|
164
|
+
* @param {string} fileName
|
|
165
|
+
* @param {number} width
|
|
166
|
+
* @param {number} fontSize
|
|
167
|
+
* @returns {string[]}
|
|
168
|
+
*/
|
|
169
|
+
static #wrapMissingImageFileName(fileName, width, fontSize) {
|
|
170
|
+
const maxWidth = Math.max(Number(width || 0), 1)
|
|
171
|
+
const value = String(fileName || '').trim()
|
|
172
|
+
const lines = []
|
|
173
|
+
let line = ''
|
|
174
|
+
let lineWidth = 0
|
|
175
|
+
|
|
176
|
+
for (const character of value) {
|
|
177
|
+
const characterWidth =
|
|
178
|
+
SchematicImageRenderer.#estimateMissingImageCharacterWidth(
|
|
179
|
+
character,
|
|
180
|
+
fontSize
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if (line && lineWidth + characterWidth > maxWidth) {
|
|
184
|
+
lines.push(line)
|
|
185
|
+
line = character
|
|
186
|
+
lineWidth = characterWidth
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
line += character
|
|
191
|
+
lineWidth += characterWidth
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (line) {
|
|
195
|
+
lines.push(line)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return lines.length ? lines : ['image file']
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Estimates one placeholder glyph width for Times-like schematic text.
|
|
203
|
+
* @param {string} character
|
|
204
|
+
* @param {number} fontSize
|
|
205
|
+
* @returns {number}
|
|
206
|
+
*/
|
|
207
|
+
static #estimateMissingImageCharacterWidth(character, fontSize) {
|
|
208
|
+
if (/[^\x00-\x7F]/u.test(character)) return fontSize
|
|
209
|
+
if (/[A-Z]/.test(character)) return fontSize * 0.62
|
|
210
|
+
if (/[a-z]/.test(character)) return fontSize * 0.45
|
|
211
|
+
if (/[0-9]/.test(character)) return fontSize * 0.5
|
|
212
|
+
if (/[\\/]/.test(character)) return fontSize * 0.32
|
|
213
|
+
if (/[.:\-_]/.test(character)) return fontSize * 0.28
|
|
214
|
+
|
|
215
|
+
return fontSize * 0.35
|
|
216
|
+
}
|
|
217
|
+
|
|
116
218
|
/**
|
|
117
219
|
* Resolves one image placement into SVG-space bounds.
|
|
118
220
|
* @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
|