altium-toolkit 1.0.1 → 1.0.7

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.
@@ -141,6 +141,7 @@
141
141
  --pcb-via-hole-fill: #0f746c;
142
142
  --pcb-footprint-fill: rgba(247, 230, 117, 0.14);
143
143
  --pcb-footprint-track-color: rgba(237, 172, 36, 1);
144
+ --pcb-text-knockout-fill: rgba(248, 246, 239, 0.96);
144
145
  --pcb-component-top-fill: rgba(244, 219, 198, 0.92);
145
146
  --pcb-component-bottom-fill: rgba(15, 116, 108, 0.84);
146
147
  --pcb-component-stroke: rgba(110, 64, 38, 0.28);
@@ -178,6 +179,8 @@
178
179
  fill: var(--pcb-board-fill);
179
180
  stroke: var(--pcb-board-stroke);
180
181
  stroke-width: 18;
182
+ stroke-linecap: round;
183
+ stroke-linejoin: round;
181
184
  }
182
185
 
183
186
  .board-outline--stroke {
@@ -252,7 +255,7 @@
252
255
  }
253
256
 
254
257
  .pcb-footprint-region {
255
- fill: var(--pcb-footprint-track-color);
258
+ fill: var(--pcb-footprint-region-fill, var(--pcb-footprint-track-color));
256
259
  }
257
260
 
258
261
  .pcb-footprint-track,
@@ -278,6 +281,14 @@
278
281
  pointer-events: none;
279
282
  }
280
283
 
284
+ .pcb-text__knockout-fill {
285
+ fill: var(--pcb-text-knockout-fill);
286
+ }
287
+
288
+ .pcb-text__knockout-glyphs {
289
+ fill: #000;
290
+ }
291
+
281
292
  .bom-panel {
282
293
  display: grid;
283
294
  gap: 1rem;
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Detects native overlay artwork that already contains text knockout holes.
7
+ */
8
+ export class PcbNativeTextKnockoutDetector {
9
+ static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
10
+ static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
11
+
12
+ /**
13
+ * Returns true when side-resolved overlay primitives carry native text
14
+ * knockouts, so source inverted TrueType labels would duplicate artwork.
15
+ * @param {{ fills?: object[], regions?: object[], tracks?: object[], arcs?: object[] }} primitives Side-resolved overlay primitives.
16
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
17
+ * @returns {boolean}
18
+ */
19
+ static hasNativeTextKnockouts(primitives, board) {
20
+ const fills = [
21
+ ...(Array.isArray(primitives?.fills) ? primitives.fills : []),
22
+ ...(Array.isArray(primitives?.regions) ? primitives.regions : [])
23
+ ]
24
+
25
+ return (
26
+ PcbNativeTextKnockoutDetector.#isDenseOverlayArtwork(
27
+ {
28
+ fills,
29
+ tracks: primitives?.tracks,
30
+ arcs: primitives?.arcs
31
+ },
32
+ board
33
+ ) &&
34
+ fills.some(
35
+ (fill) => Array.isArray(fill?.holes) && fill.holes.length > 0
36
+ )
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Detects dense overlay artwork from structural density.
42
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[] }} side Side primitives.
43
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
44
+ * @returns {boolean}
45
+ */
46
+ static #isDenseOverlayArtwork(side, board) {
47
+ const strokeCount =
48
+ (Array.isArray(side?.tracks) ? side.tracks.length : 0) +
49
+ (Array.isArray(side?.arcs) ? side.arcs.length : 0)
50
+
51
+ return (
52
+ strokeCount >=
53
+ PcbNativeTextKnockoutDetector.#DENSE_OVERLAY_MIN_TRACK_COUNT &&
54
+ PcbNativeTextKnockoutDetector.#maxFillAreaRatio(
55
+ side?.fills,
56
+ board
57
+ ) >=
58
+ PcbNativeTextKnockoutDetector
59
+ .#DENSE_OVERLAY_MIN_REGION_AREA_RATIO
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Resolves the largest fill-to-board bounding-box area ratio.
65
+ * @param {object[] | undefined} fills Fill primitives.
66
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
67
+ * @returns {number}
68
+ */
69
+ static #maxFillAreaRatio(fills, board) {
70
+ const boardArea =
71
+ Math.max(Number(board?.widthMil || 0), 0) *
72
+ Math.max(Number(board?.heightMil || 0), 0)
73
+ if (!boardArea) {
74
+ return 0
75
+ }
76
+
77
+ return (Array.isArray(fills) ? fills : []).reduce((maxRatio, fill) => {
78
+ const bounds =
79
+ PcbNativeTextKnockoutDetector.#resolveFillBounds(fill)
80
+ if (!bounds) {
81
+ return maxRatio
82
+ }
83
+
84
+ const fillArea =
85
+ Math.max(bounds.maxX - bounds.minX, 0) *
86
+ Math.max(bounds.maxY - bounds.minY, 0)
87
+
88
+ return Math.max(maxRatio, fillArea / boardArea)
89
+ }, 0)
90
+ }
91
+
92
+ /**
93
+ * Resolves rough authored bounds for one rectangular or polygon fill.
94
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x?: number, y?: number }[] }} fill Fill primitive.
95
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
96
+ */
97
+ static #resolveFillBounds(fill) {
98
+ const points = Array.isArray(fill?.points)
99
+ ? fill.points
100
+ .map((point) => ({
101
+ x: Number(point?.x),
102
+ y: Number(point?.y)
103
+ }))
104
+ .filter(
105
+ (point) =>
106
+ Number.isFinite(point.x) && Number.isFinite(point.y)
107
+ )
108
+ : [
109
+ { x: Number(fill?.x1), y: Number(fill?.y1) },
110
+ { x: Number(fill?.x2), y: Number(fill?.y2) }
111
+ ].filter(
112
+ (point) =>
113
+ Number.isFinite(point.x) && Number.isFinite(point.y)
114
+ )
115
+
116
+ if (points.length < 2) {
117
+ return null
118
+ }
119
+
120
+ const xs = points.map((point) => point.x)
121
+ const ys = points.map((point) => point.y)
122
+
123
+ return {
124
+ minX: Math.min(...xs),
125
+ minY: Math.min(...ys),
126
+ maxX: Math.max(...xs),
127
+ maxY: Math.max(...ys)
128
+ }
129
+ }
130
+ }
@@ -7,7 +7,9 @@ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs
7
7
  import { PcbEmbeddedFontFaceRenderer } from './PcbEmbeddedFontFaceRenderer.mjs'
8
8
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
9
9
  import { PcbCopperPrimitiveSplitter } from './PcbCopperPrimitiveSplitter.mjs'
10
+ import { PcbNativeTextKnockoutDetector } from './PcbNativeTextKnockoutDetector.mjs'
10
11
  import { PcbRegionPrimitiveRenderer } from './PcbRegionPrimitiveRenderer.mjs'
12
+ import { PcbScene3dBoardOutlineRefiner } from './PcbScene3dBoardOutlineRefiner.mjs'
11
13
  import { PcbTextPrimitiveRenderer } from './PcbTextPrimitiveRenderer.mjs'
12
14
  import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
13
15
  /**
@@ -27,7 +29,10 @@ export class PcbSvgRenderer {
27
29
  if (!pcb) {
28
30
  return '<section class="altium-renderer-empty">No PCB entities were recovered from this file.</section>'
29
31
  }
30
- const outline = pcb.boardOutline
32
+ const outline = PcbScene3dBoardOutlineRefiner.refine(
33
+ { board: pcb.boardOutline },
34
+ documentModel
35
+ ).board
31
36
  const polygons = pcb.polygons || []
32
37
  const fills = pcb.fills || []
33
38
  const tracks = pcb.tracks || []
@@ -43,11 +48,6 @@ export class PcbSvgRenderer {
43
48
  const stackLayers = Array.isArray(pcb.layers) ? pcb.layers : []
44
49
  const primitiveLayers = pcb.primitiveLayers || []
45
50
  const displayLayers = stackLayers.length ? stackLayers : primitiveLayers
46
- const texts = PcbTextPrimitiveRenderer.select(
47
- primitiveLayers,
48
- pcb.texts || [],
49
- 'top'
50
- )
51
51
  const copperGroups = PcbCopperPrimitiveSplitter.split(
52
52
  polygons,
53
53
  fills,
@@ -66,6 +66,18 @@ export class PcbSvgRenderer {
66
66
  ),
67
67
  outline
68
68
  )
69
+ const texts = PcbTextPrimitiveRenderer.select(
70
+ primitiveLayers,
71
+ pcb.texts || [],
72
+ 'top',
73
+ {
74
+ nativeTextKnockouts:
75
+ PcbNativeTextKnockoutDetector.hasNativeTextKnockouts(
76
+ footprintPrimitives,
77
+ outline
78
+ )
79
+ }
80
+ )
69
81
  const path = PcbSvgRenderer.#buildBoardPath(outline.segments)
70
82
  const clipPathId = 'pcb-board-clip'
71
83
  const viewBox = PcbSvgRenderer.#buildViewBox(
@@ -256,6 +268,9 @@ export class PcbSvgRenderer {
256
268
  'pcb-footprint-region'
257
269
  )
258
270
  const textMarkup = PcbTextPrimitiveRenderer.render(texts)
271
+ const textGroupTransform = PcbSvgRenderer.#renderTextGroupTransform(
272
+ pcb.textGroupTransform
273
+ )
259
274
  const fontFaceMarkup = PcbEmbeddedFontFaceRenderer.buildMarkup(
260
275
  pcb.embeddedFonts || []
261
276
  )
@@ -355,17 +370,19 @@ export class PcbSvgRenderer {
355
370
  footprintArcMarkup +
356
371
  footprintRegionMarkup +
357
372
  '</g>' +
373
+ '<g class="pcb-components">' +
374
+ componentMarkup +
375
+ '</g>' +
358
376
  '<g class="pcb-texts" clip-path="url(#' +
359
377
  clipPathId +
360
- ')">' +
378
+ ')"' +
379
+ textGroupTransform +
380
+ '>' +
361
381
  textMarkup +
362
382
  '</g>' +
363
383
  '<path class="board-outline board-outline--stroke" d="' +
364
384
  SchematicSvgUtils.escapeHtml(path) +
365
385
  '" />' +
366
- '<g class="pcb-components">' +
367
- componentMarkup +
368
- '</g>' +
369
386
  '</svg></div></section>'
370
387
  )
371
388
  }
@@ -415,6 +432,62 @@ export class PcbSvgRenderer {
415
432
  return path + ' Z'
416
433
  }
417
434
 
435
+ /**
436
+ * Renders an optional transform for the whole PCB text layer.
437
+ * @param {{ translateX?: number, translateY?: number, scaleX?: number, scaleY?: number } | undefined} transform Text group transform.
438
+ * @returns {string}
439
+ */
440
+ static #renderTextGroupTransform(transform) {
441
+ if (!transform || typeof transform !== 'object') {
442
+ return ''
443
+ }
444
+
445
+ const translateX = Number(transform.translateX || 0)
446
+ const translateY = Number(transform.translateY || 0)
447
+ const scaleX =
448
+ transform.scaleX === null || transform.scaleX === undefined
449
+ ? 1
450
+ : Number(transform.scaleX)
451
+ const scaleY =
452
+ transform.scaleY === null || transform.scaleY === undefined
453
+ ? 1
454
+ : Number(transform.scaleY)
455
+ if (
456
+ !Number.isFinite(translateX) ||
457
+ !Number.isFinite(translateY) ||
458
+ !Number.isFinite(scaleX) ||
459
+ !Number.isFinite(scaleY) ||
460
+ (translateX === 0 &&
461
+ translateY === 0 &&
462
+ scaleX === 1 &&
463
+ scaleY === 1)
464
+ ) {
465
+ return ''
466
+ }
467
+
468
+ const parts = []
469
+ if (translateX !== 0 || translateY !== 0) {
470
+ parts.push(
471
+ 'translate(' +
472
+ SchematicSvgUtils.formatNumber(translateX) +
473
+ ' ' +
474
+ SchematicSvgUtils.formatNumber(translateY) +
475
+ ')'
476
+ )
477
+ }
478
+ if (scaleX !== 1 || scaleY !== 1) {
479
+ parts.push(
480
+ 'scale(' +
481
+ SchematicSvgUtils.formatNumber(scaleX) +
482
+ ' ' +
483
+ SchematicSvgUtils.formatNumber(scaleY) +
484
+ ')'
485
+ )
486
+ }
487
+
488
+ return ' transform="' + parts.join(' ') + '"'
489
+ }
490
+
418
491
  /**
419
492
  * Computes a reasonable viewBox.
420
493
  * @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }} outline