altium-toolkit 0.1.23 → 1.0.2

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.
@@ -0,0 +1,402 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Refines Altium 3D board outlines when parser recovery emitted a rasterized
7
+ * stair-step contour even though the document still carries a smoother board
8
+ * region contour.
9
+ */
10
+ export class PcbScene3dBoardOutlineRefiner {
11
+ static #MIN_RASTERIZED_SEGMENTS = 16
12
+ static #MIN_SHORT_SEGMENT_RATIO = 0.35
13
+ static #MIN_AXIS_ALIGNED_RATIO = 0.9
14
+ static #SHORT_SEGMENT_MAX_MIL = 24
15
+ static #POINT_EPSILON_MIL = 0.25
16
+ static #AREA_RATIO_MIN = 0.75
17
+ static #AREA_RATIO_MAX = 1.25
18
+
19
+ /**
20
+ * Returns a scene description with a refined board outline when a better
21
+ * board-region contour is available.
22
+ * @param {object} sceneDescription Built scene description.
23
+ * @param {object} documentModel Source PCB document model.
24
+ * @returns {object}
25
+ */
26
+ static refine(sceneDescription, documentModel) {
27
+ const board = sceneDescription?.board
28
+ const segments = Array.isArray(board?.segments) ? board.segments : []
29
+
30
+ if (
31
+ !PcbScene3dBoardOutlineRefiner.#isRasterizedManhattanOutline(
32
+ segments
33
+ )
34
+ ) {
35
+ return sceneDescription
36
+ }
37
+
38
+ const currentOutline =
39
+ documentModel?.pcb?.boardOutline || sceneDescription?.board || {}
40
+ const candidate = PcbScene3dBoardOutlineRefiner.#selectCandidate(
41
+ documentModel?.pcb?.boardRegions,
42
+ currentOutline
43
+ )
44
+
45
+ if (!candidate) {
46
+ return sceneDescription
47
+ }
48
+
49
+ return {
50
+ ...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
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Returns true when an outline looks like a raster-grid recovery of a
66
+ * curved board route instead of authored geometric segments.
67
+ * @param {Array<Record<string, number | string>>} segments Outline segments.
68
+ * @returns {boolean}
69
+ */
70
+ static #isRasterizedManhattanOutline(segments) {
71
+ if (
72
+ segments.length <
73
+ PcbScene3dBoardOutlineRefiner.#MIN_RASTERIZED_SEGMENTS
74
+ ) {
75
+ return false
76
+ }
77
+
78
+ if (segments.some((segment) => segment.type === 'arc')) {
79
+ return false
80
+ }
81
+
82
+ let axisAlignedCount = 0
83
+ let shortSegmentCount = 0
84
+
85
+ for (const segment of segments) {
86
+ const dx = Math.abs(
87
+ Number(segment.x2 || 0) - Number(segment.x1 || 0)
88
+ )
89
+ const dy = Math.abs(
90
+ Number(segment.y2 || 0) - Number(segment.y1 || 0)
91
+ )
92
+ const length = Math.hypot(dx, dy)
93
+
94
+ if (dx <= 0.001 || dy <= 0.001) {
95
+ axisAlignedCount += 1
96
+ }
97
+ if (
98
+ length <= PcbScene3dBoardOutlineRefiner.#SHORT_SEGMENT_MAX_MIL
99
+ ) {
100
+ shortSegmentCount += 1
101
+ }
102
+ }
103
+
104
+ return (
105
+ axisAlignedCount / segments.length >=
106
+ PcbScene3dBoardOutlineRefiner.#MIN_AXIS_ALIGNED_RATIO &&
107
+ shortSegmentCount / segments.length >=
108
+ PcbScene3dBoardOutlineRefiner.#MIN_SHORT_SEGMENT_RATIO
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Chooses the best compatible board-region contour.
114
+ * @param {object[] | undefined} regions Candidate board regions.
115
+ * @param {object} currentOutline Current outline bounds and segments.
116
+ * @returns {object | null}
117
+ */
118
+ static #selectCandidate(regions, currentOutline) {
119
+ const currentBounds =
120
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(currentOutline)
121
+ if (!currentBounds) {
122
+ return null
123
+ }
124
+
125
+ const currentArea = Math.abs(
126
+ PcbScene3dBoardOutlineRefiner.#computeAreaFromSegments(
127
+ currentOutline?.segments || []
128
+ )
129
+ )
130
+ const candidates = (Array.isArray(regions) ? regions : [])
131
+ .filter((region) =>
132
+ PcbScene3dBoardOutlineRefiner.#isBoardRegionCandidate(region)
133
+ )
134
+ .map((region) =>
135
+ PcbScene3dBoardOutlineRefiner.#buildOutlineFromPoints(
136
+ region.points
137
+ )
138
+ )
139
+ .filter(Boolean)
140
+ .filter((outline) =>
141
+ PcbScene3dBoardOutlineRefiner.#boundsAreCompatible(
142
+ currentBounds,
143
+ outline
144
+ )
145
+ )
146
+ .filter((outline) =>
147
+ PcbScene3dBoardOutlineRefiner.#areaIsCompatible(
148
+ currentArea,
149
+ outline
150
+ )
151
+ )
152
+ .map((outline) => ({
153
+ outline,
154
+ score: PcbScene3dBoardOutlineRefiner.#scoreBounds(
155
+ currentBounds,
156
+ outline
157
+ )
158
+ }))
159
+ .sort((left, right) => left.score - right.score)
160
+
161
+ return candidates[0]?.outline || null
162
+ }
163
+
164
+ /**
165
+ * Returns true when a region can represent the board body boundary.
166
+ * @param {object} region Source board region.
167
+ * @returns {boolean}
168
+ */
169
+ static #isBoardRegionCandidate(region) {
170
+ return (
171
+ Array.isArray(region?.points) &&
172
+ region.points.length >= 3 &&
173
+ (region?.objectKind === 'BoardRegion' ||
174
+ region?.isBoardCutout === true ||
175
+ Number.isInteger(region?.boardRegionIndex))
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Converts one point loop into outline bounds and line segments.
181
+ * @param {{ x?: number, y?: number }[] | undefined} points Source points.
182
+ * @returns {object | null}
183
+ */
184
+ static #buildOutlineFromPoints(points) {
185
+ const normalizedPoints =
186
+ PcbScene3dBoardOutlineRefiner.#normalizePointLoop(points)
187
+
188
+ if (normalizedPoints.length < 3) {
189
+ return null
190
+ }
191
+
192
+ let minX = Number.POSITIVE_INFINITY
193
+ let minY = Number.POSITIVE_INFINITY
194
+ let maxX = Number.NEGATIVE_INFINITY
195
+ let maxY = Number.NEGATIVE_INFINITY
196
+
197
+ for (const point of normalizedPoints) {
198
+ minX = Math.min(minX, point.x)
199
+ minY = Math.min(minY, point.y)
200
+ maxX = Math.max(maxX, point.x)
201
+ maxY = Math.max(maxY, point.y)
202
+ }
203
+
204
+ const segments = normalizedPoints.map((point, index) => {
205
+ const next = normalizedPoints[(index + 1) % normalizedPoints.length]
206
+
207
+ return {
208
+ type: 'line',
209
+ x1: point.x,
210
+ y1: point.y,
211
+ x2: next.x,
212
+ y2: next.y
213
+ }
214
+ })
215
+
216
+ return {
217
+ minX,
218
+ minY,
219
+ widthMil: maxX - minX,
220
+ heightMil: maxY - minY,
221
+ segments
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Normalizes finite points and drops duplicated adjacent points.
227
+ * @param {{ x?: number, y?: number }[] | undefined} points Source points.
228
+ * @returns {{ x: number, y: number }[]}
229
+ */
230
+ static #normalizePointLoop(points) {
231
+ const output = []
232
+
233
+ for (const point of Array.isArray(points) ? points : []) {
234
+ const nextPoint = {
235
+ x: Number(point?.x),
236
+ y: Number(point?.y)
237
+ }
238
+
239
+ if (
240
+ !Number.isFinite(nextPoint.x) ||
241
+ !Number.isFinite(nextPoint.y)
242
+ ) {
243
+ continue
244
+ }
245
+
246
+ if (
247
+ output.length &&
248
+ PcbScene3dBoardOutlineRefiner.#distanceBetween(
249
+ output[output.length - 1],
250
+ nextPoint
251
+ ) <= PcbScene3dBoardOutlineRefiner.#POINT_EPSILON_MIL
252
+ ) {
253
+ continue
254
+ }
255
+
256
+ output.push(nextPoint)
257
+ }
258
+
259
+ if (
260
+ output.length > 1 &&
261
+ PcbScene3dBoardOutlineRefiner.#distanceBetween(
262
+ output[0],
263
+ output[output.length - 1]
264
+ ) <= PcbScene3dBoardOutlineRefiner.#POINT_EPSILON_MIL
265
+ ) {
266
+ output.pop()
267
+ }
268
+
269
+ return output
270
+ }
271
+
272
+ /**
273
+ * Resolves bounds from an outline object.
274
+ * @param {object} outline Outline-like object.
275
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number } | null}
276
+ */
277
+ static #resolveOutlineBounds(outline) {
278
+ const minX = Number(outline?.minX)
279
+ const minY = Number(outline?.minY)
280
+ const widthMil = Number(outline?.widthMil)
281
+ const heightMil = Number(outline?.heightMil)
282
+
283
+ if (
284
+ !Number.isFinite(minX) ||
285
+ !Number.isFinite(minY) ||
286
+ !Number.isFinite(widthMil) ||
287
+ !Number.isFinite(heightMil) ||
288
+ widthMil <= 0 ||
289
+ heightMil <= 0
290
+ ) {
291
+ return null
292
+ }
293
+
294
+ return {
295
+ minX,
296
+ minY,
297
+ maxX: minX + widthMil,
298
+ maxY: minY + heightMil,
299
+ widthMil,
300
+ heightMil
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Returns true when a candidate's envelope matches the current board.
306
+ * @param {object} current Current outline bounds.
307
+ * @param {object} candidate Candidate outline bounds.
308
+ * @returns {boolean}
309
+ */
310
+ static #boundsAreCompatible(current, candidate) {
311
+ const tolerance = Math.max(
312
+ Math.max(current.widthMil, current.heightMil) * 0.06,
313
+ 40
314
+ )
315
+ const candidateBounds =
316
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(candidate)
317
+
318
+ if (!candidateBounds) {
319
+ return false
320
+ }
321
+
322
+ return (
323
+ Math.abs(current.minX - candidateBounds.minX) <= tolerance &&
324
+ Math.abs(current.minY - candidateBounds.minY) <= tolerance &&
325
+ Math.abs(current.maxX - candidateBounds.maxX) <= tolerance &&
326
+ Math.abs(current.maxY - candidateBounds.maxY) <= tolerance
327
+ )
328
+ }
329
+
330
+ /**
331
+ * Returns true when the candidate area is close enough to the fallback.
332
+ * @param {number} currentArea Current outline area.
333
+ * @param {object} candidate Candidate outline.
334
+ * @returns {boolean}
335
+ */
336
+ static #areaIsCompatible(currentArea, candidate) {
337
+ if (!currentArea) {
338
+ return true
339
+ }
340
+
341
+ const candidateArea = Math.abs(
342
+ PcbScene3dBoardOutlineRefiner.#computeAreaFromSegments(
343
+ candidate?.segments || []
344
+ )
345
+ )
346
+ const ratio = candidateArea / currentArea
347
+
348
+ return (
349
+ ratio >= PcbScene3dBoardOutlineRefiner.#AREA_RATIO_MIN &&
350
+ ratio <= PcbScene3dBoardOutlineRefiner.#AREA_RATIO_MAX
351
+ )
352
+ }
353
+
354
+ /**
355
+ * Scores how closely candidate bounds match current bounds.
356
+ * @param {object} current Current outline bounds.
357
+ * @param {object} candidate Candidate outline bounds.
358
+ * @returns {number}
359
+ */
360
+ static #scoreBounds(current, candidate) {
361
+ const candidateBounds =
362
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(candidate)
363
+
364
+ if (!candidateBounds) {
365
+ return Number.POSITIVE_INFINITY
366
+ }
367
+
368
+ return (
369
+ Math.abs(current.minX - candidateBounds.minX) +
370
+ Math.abs(current.minY - candidateBounds.minY) +
371
+ Math.abs(current.maxX - candidateBounds.maxX) +
372
+ Math.abs(current.maxY - candidateBounds.maxY)
373
+ )
374
+ }
375
+
376
+ /**
377
+ * Computes signed area from line segments.
378
+ * @param {Array<Record<string, number | string>>} segments Outline segments.
379
+ * @returns {number}
380
+ */
381
+ static #computeAreaFromSegments(segments) {
382
+ let area = 0
383
+
384
+ for (const segment of segments) {
385
+ area +=
386
+ Number(segment.x1 || 0) * Number(segment.y2 || 0) -
387
+ Number(segment.x2 || 0) * Number(segment.y1 || 0)
388
+ }
389
+
390
+ return area / 2
391
+ }
392
+
393
+ /**
394
+ * Measures the distance between two points.
395
+ * @param {{ x: number, y: number }} left First point.
396
+ * @param {{ x: number, y: number }} right Second point.
397
+ * @returns {number}
398
+ */
399
+ static #distanceBetween(left, right) {
400
+ return Math.hypot(right.x - left.x, right.y - left.y)
401
+ }
402
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs'
6
+ import { PcbScene3dBoardOutlineRefiner } from './PcbScene3dBoardOutlineRefiner.mjs'
6
7
  import { PcbScene3dDrillCutoutBuilder } from './PcbScene3dDrillCutoutBuilder.mjs'
7
8
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
8
9
  import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
@@ -99,7 +100,7 @@ export class PcbScene3dBuilder {
99
100
  appearance3d
100
101
  )
101
102
 
102
- return {
103
+ const sceneDescription = {
103
104
  sourceFormat: 'altium',
104
105
  board,
105
106
  components: components.map((component) =>
@@ -139,6 +140,11 @@ export class PcbScene3dBuilder {
139
140
  }
140
141
  }
141
142
  }
143
+
144
+ return PcbScene3dBoardOutlineRefiner.refine(
145
+ sceneDescription,
146
+ documentModel
147
+ )
142
148
  }
143
149
 
144
150
  /**
@@ -729,6 +735,11 @@ export class PcbScene3dBuilder {
729
735
  boardOutline
730
736
  )
731
737
 
738
+ const drillCutouts = PcbScene3dDrillCutoutBuilder.buildCutouts(
739
+ pads,
740
+ vias
741
+ )
742
+
732
743
  return {
733
744
  ...normalized,
734
745
  denseOverlayArtwork,
@@ -743,11 +754,11 @@ export class PcbScene3dBuilder {
743
754
  side
744
755
  ),
745
756
  tracks: normalized.tracks,
746
- fills: PcbScene3dDrillCutoutBuilder.clipFills(
757
+ fills: PcbScene3dDrillCutoutBuilder.clipFillsWithCutouts(
747
758
  fillsWithRegions,
748
- pads,
749
- vias
750
- )
759
+ drillCutouts
760
+ ),
761
+ drillCutouts: drillCutouts.map((cutout) => cutout.points)
751
762
  }
752
763
  }
753
764
 
@@ -10,6 +10,19 @@ export class PcbScene3dDrillCutoutBuilder {
10
10
  static #SLOT_CAP_SEGMENTS = 12
11
11
  static #EPSILON = 0.001
12
12
 
13
+ /**
14
+ * Builds all known drill cutout contours.
15
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
16
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
17
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
18
+ */
19
+ static buildCutouts(pads, vias) {
20
+ return [
21
+ ...PcbScene3dDrillCutoutBuilder.#buildPadCutouts(pads),
22
+ ...PcbScene3dDrillCutoutBuilder.#buildViaCutouts(vias)
23
+ ]
24
+ }
25
+
13
26
  /**
14
27
  * Adds drill-shaped holes to every fill intersected by a pad or via drill.
15
28
  * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]} fills
@@ -18,9 +31,20 @@ export class PcbScene3dDrillCutoutBuilder {
18
31
  * @returns {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]}
19
32
  */
20
33
  static clipFills(fills, pads, vias) {
21
- const cutouts = PcbScene3dDrillCutoutBuilder.#buildCutouts(pads, vias)
34
+ return PcbScene3dDrillCutoutBuilder.clipFillsWithCutouts(
35
+ fills,
36
+ PcbScene3dDrillCutoutBuilder.buildCutouts(pads, vias)
37
+ )
38
+ }
22
39
 
23
- if (!cutouts.length) {
40
+ /**
41
+ * Adds drill-shaped holes from precomputed cutouts.
42
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]} fills
43
+ * @param {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]} cutouts
44
+ * @returns {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]}
45
+ */
46
+ static clipFillsWithCutouts(fills, cutouts) {
47
+ if (!Array.isArray(cutouts) || !cutouts.length) {
24
48
  return fills
25
49
  }
26
50
 
@@ -48,19 +72,6 @@ export class PcbScene3dDrillCutoutBuilder {
48
72
  })
49
73
  }
50
74
 
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
75
  /**
65
76
  * Builds drill contours for drilled pads.
66
77
  * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
@@ -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