altium-toolkit 0.1.0

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.
Files changed (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,906 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbArcUtils } from './PcbArcUtils.mjs'
6
+ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs'
7
+ import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
8
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
9
+ /**
10
+ * Renders normalized PCB models into HTML and SVG markup.
11
+ */
12
+ export class PcbSvgRenderer {
13
+ static #PAD_SHAPE_RECTANGULAR = 2
14
+ static #PAD_HOLE_SHAPE_SLOT = 2
15
+ static #GENERIC_DETAIL_SEARCH_HALF_EXTENT = 240
16
+ /**
17
+ * Renders a normalized PCB model into HTML and SVG markup.
18
+ * @param {{ summary: { title?: string }, pcb?: { boardOutline: { segments: Array<Record<string, number | string>>, minX: number, minY: number, widthMil: number, heightMil: number }, layers: { name: string }[], primitiveLayers?: { layerId: number, name: string }[], polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, shapeTop?: number, shapeMid?: number, shapeBottom?: number, rotation?: number, isPlated?: boolean }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] } }} documentModel
19
+ * @returns {string}
20
+ */
21
+ static render(documentModel) {
22
+ const pcb = documentModel?.pcb
23
+ if (!pcb) {
24
+ return '<section class="altium-renderer-empty">No PCB entities were recovered from this file.</section>'
25
+ }
26
+ const outline = pcb.boardOutline
27
+ const polygons = pcb.polygons || []
28
+ const fills = pcb.fills || []
29
+ const tracks = pcb.tracks || []
30
+ const arcs = pcb.arcs || []
31
+ const vias = pcb.vias || []
32
+ const pads = pcb.pads || []
33
+ const components = pcb.components.slice(0, 260)
34
+ const copperGroups = PcbSvgRenderer.#splitCopperPrimitives(
35
+ polygons,
36
+ fills,
37
+ tracks,
38
+ arcs
39
+ )
40
+ const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
41
+ PcbFootprintPrimitiveSelector.select(
42
+ pcb.primitiveLayers || [],
43
+ fills,
44
+ tracks,
45
+ arcs,
46
+ 'top'
47
+ ),
48
+ outline
49
+ )
50
+ const path = PcbSvgRenderer.#buildBoardPath(outline.segments)
51
+ const clipPathId = 'pcb-board-clip'
52
+ const viewBox = PcbSvgRenderer.#buildViewBox(
53
+ outline,
54
+ components,
55
+ [
56
+ ...copperGroups.surface.polygons,
57
+ ...copperGroups.subsurface.polygons
58
+ ],
59
+ [
60
+ ...copperGroups.surface.fills,
61
+ ...copperGroups.subsurface.fills,
62
+ ...footprintPrimitives.fills
63
+ ],
64
+ [
65
+ ...copperGroups.surface.tracks,
66
+ ...copperGroups.subsurface.tracks,
67
+ ...footprintPrimitives.tracks
68
+ ],
69
+ [
70
+ ...copperGroups.surface.arcs,
71
+ ...copperGroups.subsurface.arcs,
72
+ ...footprintPrimitives.arcs
73
+ ],
74
+ vias,
75
+ pads
76
+ )
77
+ const layerMarkup = pcb.layers
78
+ .slice(0, 10)
79
+ .map(
80
+ (layer) =>
81
+ '<li>' + SchematicSvgUtils.escapeHtml(layer.name) + '</li>'
82
+ )
83
+ .join('')
84
+ const polygonMarkup = (polygonList, visibilityClass) =>
85
+ polygonList
86
+ .map(
87
+ (polygon) =>
88
+ '<path class="pcb-polygon pcb-polygon--' +
89
+ visibilityClass +
90
+ '" d="' +
91
+ SchematicSvgUtils.escapeHtml(
92
+ PcbSvgRenderer.#buildBoardPath(polygon.segments)
93
+ ) +
94
+ '" />'
95
+ )
96
+ .join('')
97
+ const fillMarkup = (fillList, visibilityClass) =>
98
+ fillList
99
+ .map((fill) => {
100
+ const x = Math.min(fill.x1, fill.x2)
101
+ const y = Math.min(fill.y1, fill.y2)
102
+ const width = Math.abs(fill.x2 - fill.x1)
103
+ const height = Math.abs(fill.y2 - fill.y1)
104
+
105
+ return (
106
+ '<rect class="pcb-fill pcb-fill--' +
107
+ visibilityClass +
108
+ '" x="' +
109
+ SchematicSvgUtils.formatNumber(x) +
110
+ '" y="' +
111
+ SchematicSvgUtils.formatNumber(y) +
112
+ '" width="' +
113
+ SchematicSvgUtils.formatNumber(width) +
114
+ '" height="' +
115
+ SchematicSvgUtils.formatNumber(height) +
116
+ '" rx="' +
117
+ SchematicSvgUtils.formatNumber(
118
+ Math.min(width, height) / 6
119
+ ) +
120
+ '" />'
121
+ )
122
+ })
123
+ .join('')
124
+ const trackMarkup = (trackList, visibilityClass) =>
125
+ trackList
126
+ .map(
127
+ (track) =>
128
+ '<line class="pcb-track pcb-track--' +
129
+ visibilityClass +
130
+ '" x1="' +
131
+ SchematicSvgUtils.formatNumber(track.x1) +
132
+ '" y1="' +
133
+ SchematicSvgUtils.formatNumber(track.y1) +
134
+ '" x2="' +
135
+ SchematicSvgUtils.formatNumber(track.x2) +
136
+ '" y2="' +
137
+ SchematicSvgUtils.formatNumber(track.y2) +
138
+ '" stroke-width="' +
139
+ SchematicSvgUtils.formatNumber(
140
+ Math.max(track.width || 0, 1)
141
+ ) +
142
+ '" />'
143
+ )
144
+ .join('')
145
+ const arcMarkup = (arcList, visibilityClass) =>
146
+ arcList
147
+ .map((arc) =>
148
+ PcbArcUtils.buildMarkup(
149
+ arc,
150
+ 'pcb-arc pcb-arc--' + visibilityClass
151
+ )
152
+ )
153
+ .join('')
154
+ const viaMarkup = vias
155
+ .map((via) => {
156
+ const ringRadius = Math.max((via.diameter || 0) / 2, 1)
157
+ const holeRadius = Math.max((via.holeDiameter || 0) / 2, 0.6)
158
+ return (
159
+ '<g class="pcb-via">' +
160
+ '<circle class="pcb-via__pad" cx="' +
161
+ SchematicSvgUtils.formatNumber(via.x) +
162
+ '" cy="' +
163
+ SchematicSvgUtils.formatNumber(via.y) +
164
+ '" r="' +
165
+ SchematicSvgUtils.formatNumber(ringRadius) +
166
+ '" />' +
167
+ '<circle class="pcb-via__hole" cx="' +
168
+ SchematicSvgUtils.formatNumber(via.x) +
169
+ '" cy="' +
170
+ SchematicSvgUtils.formatNumber(via.y) +
171
+ '" r="' +
172
+ SchematicSvgUtils.formatNumber(holeRadius) +
173
+ '" />' +
174
+ '</g>'
175
+ )
176
+ })
177
+ .join('')
178
+ const padMarkup = pads
179
+ .map((pad) => PcbSvgRenderer.#renderPad(pad))
180
+ .join('')
181
+ const footprintFillMarkup = footprintPrimitives.fills
182
+ .map((fill) => {
183
+ const x = Math.min(fill.x1, fill.x2)
184
+ const y = Math.min(fill.y1, fill.y2)
185
+ const width = Math.abs(fill.x2 - fill.x1)
186
+ const height = Math.abs(fill.y2 - fill.y1)
187
+ return (
188
+ '<rect class="pcb-footprint-fill" x="' +
189
+ SchematicSvgUtils.formatNumber(x) +
190
+ '" y="' +
191
+ SchematicSvgUtils.formatNumber(y) +
192
+ '" width="' +
193
+ SchematicSvgUtils.formatNumber(width) +
194
+ '" height="' +
195
+ SchematicSvgUtils.formatNumber(height) +
196
+ '" rx="' +
197
+ SchematicSvgUtils.formatNumber(
198
+ Math.min(width, height) / 6
199
+ ) +
200
+ '" />'
201
+ )
202
+ })
203
+ .join('')
204
+ const footprintTrackMarkup = footprintPrimitives.tracks
205
+ .map(
206
+ (track) =>
207
+ '<line class="pcb-footprint-track" x1="' +
208
+ SchematicSvgUtils.formatNumber(track.x1) +
209
+ '" y1="' +
210
+ SchematicSvgUtils.formatNumber(track.y1) +
211
+ '" x2="' +
212
+ SchematicSvgUtils.formatNumber(track.x2) +
213
+ '" y2="' +
214
+ SchematicSvgUtils.formatNumber(track.y2) +
215
+ '" stroke-width="' +
216
+ SchematicSvgUtils.formatNumber(
217
+ Math.max(track.width || 0, 1)
218
+ ) +
219
+ '" />'
220
+ )
221
+ .join('')
222
+ const footprintArcMarkup = footprintPrimitives.arcs
223
+ .map((arc) => PcbArcUtils.buildMarkup(arc, 'pcb-footprint-arc'))
224
+ .join('')
225
+
226
+ const componentMarkup = components
227
+ .map((component) => {
228
+ const bodyGeometry = PcbSvgRenderer.#footprintSize(
229
+ component.pattern
230
+ )
231
+ const bodyMarkup = PcbSvgRenderer.#hasAuthoredFootprintDetail(
232
+ component,
233
+ footprintPrimitives,
234
+ pads
235
+ )
236
+ ? ''
237
+ : '<rect class="pcb-component__body" x="' +
238
+ SchematicSvgUtils.formatNumber(-bodyGeometry.width / 2) +
239
+ '" y="' +
240
+ SchematicSvgUtils.formatNumber(-bodyGeometry.height / 2) +
241
+ '" width="' +
242
+ SchematicSvgUtils.formatNumber(bodyGeometry.width) +
243
+ '" height="' +
244
+ SchematicSvgUtils.formatNumber(bodyGeometry.height) +
245
+ '" rx="' +
246
+ SchematicSvgUtils.formatNumber(
247
+ Math.max(bodyGeometry.height / 5, 4)
248
+ ) +
249
+ '" />'
250
+ return (
251
+ '<g class="pcb-component pcb-component--' +
252
+ SchematicSvgUtils.escapeHtml(
253
+ component.layer.toLowerCase()
254
+ ) +
255
+ '" transform="translate(' +
256
+ SchematicSvgUtils.formatNumber(component.x) +
257
+ ' ' +
258
+ SchematicSvgUtils.formatNumber(component.y) +
259
+ ') rotate(' +
260
+ SchematicSvgUtils.formatNumber(component.rotation) +
261
+ ')">' +
262
+ bodyMarkup +
263
+ '<text x="0" y="' +
264
+ SchematicSvgUtils.formatNumber(
265
+ bodyGeometry.height * -0.75
266
+ ) +
267
+ '">' +
268
+ SchematicSvgUtils.escapeHtml(component.designator) +
269
+ '</text></g>'
270
+ )
271
+ })
272
+ .join('')
273
+ return (
274
+ '<section class="svg-panel">' +
275
+ '<header class="svg-panel__header"><h3>' +
276
+ SchematicSvgUtils.escapeHtml(
277
+ documentModel?.summary?.title || 'PCB'
278
+ ) +
279
+ '</h3><p>' +
280
+ components.length +
281
+ ' placements, ' +
282
+ pcb.layers.length +
283
+ ' layers</p></header>' +
284
+ '<div class="pcb-layout">' +
285
+ '<aside class="pcb-legend"><h4>Board stack</h4><p>Top-facing composite view</p><ul>' +
286
+ layerMarkup +
287
+ '</ul></aside>' +
288
+ '<svg class="pcb-svg" viewBox="' +
289
+ SchematicSvgUtils.escapeHtml(viewBox) +
290
+ '" preserveAspectRatio="xMidYMid meet" aria-label="PCB view">' +
291
+ '<defs><clipPath id="' +
292
+ clipPathId +
293
+ '"><path d="' +
294
+ SchematicSvgUtils.escapeHtml(path) +
295
+ '" /></clipPath></defs>' +
296
+ '<path class="board-outline" d="' +
297
+ SchematicSvgUtils.escapeHtml(path) +
298
+ '" />' +
299
+ '<g class="pcb-copper-layers" clip-path="url(#' +
300
+ clipPathId +
301
+ ')">' +
302
+ '<g class="pcb-copper pcb-copper--subsurface">' +
303
+ polygonMarkup(copperGroups.subsurface.polygons, 'subsurface') +
304
+ fillMarkup(copperGroups.subsurface.fills, 'subsurface') +
305
+ trackMarkup(copperGroups.subsurface.tracks, 'subsurface') +
306
+ arcMarkup(copperGroups.subsurface.arcs, 'subsurface') +
307
+ '</g>' +
308
+ '<g class="pcb-copper pcb-copper--surface">' +
309
+ polygonMarkup(copperGroups.surface.polygons, 'surface') +
310
+ fillMarkup(copperGroups.surface.fills, 'surface') +
311
+ trackMarkup(copperGroups.surface.tracks, 'surface') +
312
+ arcMarkup(copperGroups.surface.arcs, 'surface') +
313
+ padMarkup +
314
+ viaMarkup +
315
+ '</g>' +
316
+ '</g>' +
317
+ '<g class="pcb-footprints">' +
318
+ footprintFillMarkup +
319
+ footprintTrackMarkup +
320
+ footprintArcMarkup +
321
+ '</g>' +
322
+ '<path class="board-outline board-outline--stroke" d="' +
323
+ SchematicSvgUtils.escapeHtml(path) +
324
+ '" />' +
325
+ '<g class="pcb-components">' +
326
+ componentMarkup +
327
+ '</g>' +
328
+ '</svg></div></section>'
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Builds a best-effort board path from outline segments.
334
+ * @param {Array<Record<string, number | string>>} segments
335
+ * @returns {string}
336
+ */
337
+ static #buildBoardPath(segments) {
338
+ if (!segments.length) {
339
+ return 'M 0 0 L 1000 0 L 1000 600 L 0 600 Z'
340
+ }
341
+ const [first] = segments
342
+ let path =
343
+ 'M ' +
344
+ SchematicSvgUtils.formatNumber(first.x1) +
345
+ ' ' +
346
+ SchematicSvgUtils.formatNumber(first.y1)
347
+
348
+ for (const segment of segments) {
349
+ if (segment.type === 'arc') {
350
+ const radius = Math.max(Number(segment.radius) || 0, 1)
351
+ const sweep = PcbArcUtils.resolveShortSweepFromCenter(segment)
352
+ path +=
353
+ ' A ' +
354
+ SchematicSvgUtils.formatNumber(radius) +
355
+ ' ' +
356
+ SchematicSvgUtils.formatNumber(radius) +
357
+ ' 0 ' +
358
+ '0' +
359
+ ' ' +
360
+ sweep +
361
+ ' ' +
362
+ SchematicSvgUtils.formatNumber(segment.x2) +
363
+ ' ' +
364
+ SchematicSvgUtils.formatNumber(segment.y2)
365
+ continue
366
+ }
367
+ path +=
368
+ ' L ' +
369
+ SchematicSvgUtils.formatNumber(segment.x2) +
370
+ ' ' +
371
+ SchematicSvgUtils.formatNumber(segment.y2)
372
+ }
373
+
374
+ return path + ' Z'
375
+ }
376
+
377
+ /**
378
+ * Computes a reasonable viewBox.
379
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }} outline
380
+ * @param {{ x: number, y: number }[]} components
381
+ * @param {{ segments: Array<Record<string, number | string>> }[]} polygons
382
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} fills
383
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} tracks
384
+ * @param {{ x: number, y: number, radius: number, width?: number }[]} arcs
385
+ * @param {{ x: number, y: number, diameter: number }[]} vias
386
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }[]} pads
387
+ * @returns {string}
388
+ */
389
+ static #buildViewBox(
390
+ outline,
391
+ components,
392
+ polygons,
393
+ fills,
394
+ tracks,
395
+ arcs,
396
+ vias,
397
+ pads
398
+ ) {
399
+ const xs = [outline.minX, outline.minX + outline.widthMil]
400
+ const ys = [outline.minY, outline.minY + outline.heightMil]
401
+ for (const segment of outline.segments || []) {
402
+ xs.push(Number(segment.x1) || 0, Number(segment.x2) || 0)
403
+ ys.push(Number(segment.y1) || 0, Number(segment.y2) || 0)
404
+ }
405
+
406
+ for (const polygon of polygons) {
407
+ for (const segment of polygon.segments || []) {
408
+ xs.push(Number(segment.x1) || 0, Number(segment.x2) || 0)
409
+ ys.push(Number(segment.y1) || 0, Number(segment.y2) || 0)
410
+ }
411
+ }
412
+
413
+ for (const fill of fills) {
414
+ xs.push(fill.x1, fill.x2)
415
+ ys.push(fill.y1, fill.y2)
416
+ }
417
+
418
+ for (const track of tracks) {
419
+ xs.push(track.x1, track.x2)
420
+ ys.push(track.y1, track.y2)
421
+ }
422
+
423
+ for (const arc of arcs) {
424
+ PcbArcUtils.pushExtents(xs, ys, arc)
425
+ }
426
+
427
+ for (const via of vias) {
428
+ const radius = (via.diameter || 0) / 2
429
+ xs.push(via.x - radius, via.x + radius)
430
+ ys.push(via.y - radius, via.y + radius)
431
+ }
432
+ for (const pad of pads) {
433
+ const size = PcbSvgRenderer.#resolvePadSurfaceSize(pad)
434
+ xs.push(pad.x - size.width / 2, pad.x + size.width / 2)
435
+ ys.push(pad.y - size.height / 2, pad.y + size.height / 2)
436
+ }
437
+ for (const component of components) {
438
+ const bodyGeometry = PcbSvgRenderer.#footprintSize(
439
+ component.pattern
440
+ )
441
+ xs.push(
442
+ component.x - bodyGeometry.width / 2,
443
+ component.x + bodyGeometry.width / 2
444
+ )
445
+ ys.push(
446
+ component.y - bodyGeometry.height / 2,
447
+ component.y + bodyGeometry.height / 2
448
+ )
449
+ }
450
+
451
+ const minX = Math.min(...xs)
452
+ const minY = Math.min(...ys)
453
+ const maxX = Math.max(...xs)
454
+ const maxY = Math.max(...ys)
455
+ const padding = 240
456
+ return [
457
+ minX - padding,
458
+ minY - padding,
459
+ maxX - minX + padding * 2,
460
+ maxY - minY + padding * 2
461
+ ]
462
+ .map((value) => SchematicSvgUtils.formatNumber(value))
463
+ .join(' ')
464
+ }
465
+
466
+ /**
467
+ * Resolves a small footprint-size heuristic and whether the pattern matched
468
+ * a known package family instead of the generic fallback guess.
469
+ * @param {string} pattern
470
+ * @returns {{ width: number, height: number, isRecognized: boolean }}
471
+ */
472
+ static #footprintProfile(pattern) {
473
+ const normalized = String(pattern || '').toUpperCase()
474
+ if (normalized.includes('0402')) {
475
+ return { width: 52, height: 28, isRecognized: true }
476
+ }
477
+ if (normalized.includes('0603')) {
478
+ return { width: 72, height: 36, isRecognized: true }
479
+ }
480
+ if (normalized.includes('0805')) {
481
+ return { width: 92, height: 48, isRecognized: true }
482
+ }
483
+ if (normalized.includes('SOT')) {
484
+ return { width: 140, height: 90, isRecognized: true }
485
+ }
486
+ if (normalized.includes('QFN') || normalized.includes('QFP')) {
487
+ return { width: 180, height: 180, isRecognized: true }
488
+ }
489
+ if (normalized.includes('SC70')) {
490
+ return { width: 110, height: 70, isRecognized: true }
491
+ }
492
+
493
+ return { width: 96, height: 60, isRecognized: false }
494
+ }
495
+
496
+ /**
497
+ * Returns a small footprint size heuristic for fallback body rendering.
498
+ * @param {string} pattern
499
+ * @returns {{ width: number, height: number }}
500
+ */
501
+ static #footprintSize(pattern) {
502
+ const footprint = PcbSvgRenderer.#footprintProfile(pattern)
503
+ return {
504
+ width: footprint.width,
505
+ height: footprint.height
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Builds the local search box used to decide whether a component already
511
+ * has authored pads or outline primitives and therefore should not render
512
+ * a synthetic rounded body.
513
+ * @param {{ x: number, y: number, pattern: string }} component
514
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
515
+ */
516
+ static #footprintDetailBounds(component) {
517
+ const footprint = PcbSvgRenderer.#footprintProfile(component.pattern)
518
+ const halfWidth = footprint.isRecognized
519
+ ? footprint.width / 2 + 36
520
+ : PcbSvgRenderer.#GENERIC_DETAIL_SEARCH_HALF_EXTENT
521
+ const halfHeight = footprint.isRecognized
522
+ ? footprint.height / 2 + 36
523
+ : PcbSvgRenderer.#GENERIC_DETAIL_SEARCH_HALF_EXTENT
524
+
525
+ return {
526
+ minX: Number(component.x) - halfWidth,
527
+ maxX: Number(component.x) + halfWidth,
528
+ minY: Number(component.y) - halfHeight,
529
+ maxY: Number(component.y) + halfHeight
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Chooses one visible through-hole pad size for top-view rendering.
535
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }} pad
536
+ * @returns {{ width: number, height: number }}
537
+ */
538
+ static #resolvePadSurfaceSize(pad) {
539
+ const width =
540
+ Number(pad.sizeTopX || pad.sizeMidX || pad.sizeBottomX || 0) ||
541
+ Number(pad.holeDiameter || 0)
542
+ const height =
543
+ Number(pad.sizeTopY || pad.sizeMidY || pad.sizeBottomY || 0) ||
544
+ Number(pad.holeDiameter || 0)
545
+
546
+ return {
547
+ width: Math.max(width, Number(pad.holeDiameter || 0), 1),
548
+ height: Math.max(height, Number(pad.holeDiameter || 0), 1)
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Renders one through-hole pad as SVG.
554
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, shapeTop?: number, rotation?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null, offsetTopX?: number, offsetTopY?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, cornerRadiusTop?: number | null }} pad
555
+ * @returns {string}
556
+ */
557
+ static #renderPad(pad) {
558
+ const size = PcbSvgRenderer.#resolvePadSurfaceSize(pad)
559
+ const padIsCircular = PcbSvgRenderer.#isCircularPad(pad, size)
560
+ const ringRadius = Math.max(Math.max(size.width, size.height) / 2, 0.6)
561
+ const offsetX = Number(pad.offsetTopX || 0)
562
+ const offsetY = Number(pad.offsetTopY || 0)
563
+ const hasHole = Number(pad.holeDiameter || 0) > 0
564
+ const ringMarkup = padIsCircular
565
+ ? '<circle class="pcb-pad__ring" cx="' +
566
+ SchematicSvgUtils.formatNumber(offsetX) +
567
+ '" cy="' +
568
+ SchematicSvgUtils.formatNumber(offsetY) +
569
+ '" r="' +
570
+ SchematicSvgUtils.formatNumber(ringRadius) +
571
+ '" />'
572
+ : '<rect class="pcb-pad__ring" x="' +
573
+ SchematicSvgUtils.formatNumber(offsetX - size.width / 2) +
574
+ '" y="' +
575
+ SchematicSvgUtils.formatNumber(offsetY - size.height / 2) +
576
+ '" width="' +
577
+ SchematicSvgUtils.formatNumber(size.width) +
578
+ '" height="' +
579
+ SchematicSvgUtils.formatNumber(size.height) +
580
+ '" rx="' +
581
+ SchematicSvgUtils.formatNumber(
582
+ PcbSvgRenderer.#resolvePadCornerRadius(pad, size)
583
+ ) +
584
+ '" />'
585
+ const holeMarkup = PcbSvgRenderer.#renderPadHole(pad)
586
+
587
+ return (
588
+ '<g class="pcb-pad pcb-pad--' +
589
+ (padIsCircular ? 'round' : 'shaped') +
590
+ ' pcb-pad--' +
591
+ (hasHole ? 'through-hole' : 'smd') +
592
+ '" transform="translate(' +
593
+ SchematicSvgUtils.formatNumber(pad.x) +
594
+ ' ' +
595
+ SchematicSvgUtils.formatNumber(pad.y) +
596
+ ') rotate(' +
597
+ SchematicSvgUtils.formatNumber(Number(pad.rotation || 0)) +
598
+ ')">' +
599
+ ringMarkup +
600
+ holeMarkup +
601
+ '</g>'
602
+ )
603
+ }
604
+
605
+ /**
606
+ * Renders one pad drill hole as SVG.
607
+ * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null }} pad
608
+ * @returns {string}
609
+ */
610
+ static #renderPadHole(pad) {
611
+ if (Number(pad.holeDiameter || 0) <= 0) {
612
+ return ''
613
+ }
614
+
615
+ const holeDiameter = Math.max(Number(pad.holeDiameter || 0), 1.2)
616
+ const holeRadius = Math.max(holeDiameter / 2, 0.6)
617
+
618
+ if (PcbSvgRenderer.#isSlotHole(pad)) {
619
+ const slotLength = Math.max(
620
+ Number(pad.holeSlotLength || 0),
621
+ holeDiameter
622
+ )
623
+
624
+ return (
625
+ '<g class="pcb-pad__hole-rotation" transform="rotate(' +
626
+ SchematicSvgUtils.formatNumber(Number(pad.holeRotation || 0)) +
627
+ ')">' +
628
+ '<rect class="pcb-pad__hole pcb-pad__hole--slot" x="' +
629
+ SchematicSvgUtils.formatNumber(-slotLength / 2) +
630
+ '" y="' +
631
+ SchematicSvgUtils.formatNumber(-holeDiameter / 2) +
632
+ '" width="' +
633
+ SchematicSvgUtils.formatNumber(slotLength) +
634
+ '" height="' +
635
+ SchematicSvgUtils.formatNumber(holeDiameter) +
636
+ '" rx="' +
637
+ SchematicSvgUtils.formatNumber(holeRadius) +
638
+ '" />' +
639
+ '</g>'
640
+ )
641
+ }
642
+
643
+ return (
644
+ '<circle class="pcb-pad__hole" cx="0" cy="0" r="' +
645
+ SchematicSvgUtils.formatNumber(holeRadius) +
646
+ '" />'
647
+ )
648
+ }
649
+
650
+ /**
651
+ * Returns true when one through-hole pad should render as a circular ring.
652
+ * @param {{ shapeTop?: number, hasRoundedRect?: boolean }} pad
653
+ * @param {{ width: number, height: number }} size
654
+ * @returns {boolean}
655
+ */
656
+ static #isCircularPad(pad, size) {
657
+ const effectiveShape = PcbSvgRenderer.#resolvePadShape(pad)
658
+
659
+ if (effectiveShape === PcbSvgRenderer.#PAD_SHAPE_RECTANGULAR) {
660
+ return false
661
+ }
662
+
663
+ return Math.abs(Number(size.width) - Number(size.height)) < 0.001
664
+ }
665
+
666
+ /**
667
+ * Returns true when one component already has authored local geometry from
668
+ * selected top-side documentation layers.
669
+ * @param {{ x: number, y: number, pattern: string }} component
670
+ * @param {{ fills: { x1: number, y1: number, x2: number, y2: number }[], tracks: { x1: number, y1: number, x2: number, y2: number }[], arcs: { x: number, y: number, radius: number, width?: number }[] }} footprintPrimitives
671
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, rotation?: number, offsetTopX?: number, offsetTopY?: number, holeDiameter?: number }[]} pads
672
+ * @returns {boolean}
673
+ */
674
+ static #hasAuthoredFootprintDetail(component, footprintPrimitives, pads) {
675
+ const bounds = PcbSvgRenderer.#footprintDetailBounds(component)
676
+
677
+ return (
678
+ (footprintPrimitives.tracks || []).some((track) =>
679
+ PcbSvgRenderer.#trackIntersectsBounds(track, bounds)
680
+ ) ||
681
+ (footprintPrimitives.fills || []).some((fill) =>
682
+ PcbSvgRenderer.#fillIntersectsBounds(fill, bounds)
683
+ ) ||
684
+ (footprintPrimitives.arcs || []).some((arc) =>
685
+ PcbArcUtils.intersectsBounds(arc, bounds)
686
+ ) ||
687
+ (pads || []).some((pad) =>
688
+ PcbSvgRenderer.#padIntersectsBounds(pad, bounds)
689
+ )
690
+ )
691
+ }
692
+
693
+ /**
694
+ * Returns true when one recovered pad surface overlaps a component-local
695
+ * search box, which means the footprint already has concrete 2D items.
696
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, rotation?: number, offsetTopX?: number, offsetTopY?: number, holeDiameter?: number }} pad
697
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
698
+ * @returns {boolean}
699
+ */
700
+ static #padIntersectsBounds(pad, bounds) {
701
+ const size = PcbSvgRenderer.#resolvePadSurfaceSize(pad)
702
+ const rotationRadians = (Number(pad.rotation || 0) * Math.PI) / 180
703
+ const boxWidth =
704
+ Math.abs(size.width * Math.cos(rotationRadians)) +
705
+ Math.abs(size.height * Math.sin(rotationRadians))
706
+ const boxHeight =
707
+ Math.abs(size.width * Math.sin(rotationRadians)) +
708
+ Math.abs(size.height * Math.cos(rotationRadians))
709
+ const centerX = Number(pad.x || 0) + Number(pad.offsetTopX || 0)
710
+ const centerY = Number(pad.y || 0) + Number(pad.offsetTopY || 0)
711
+ const minX = centerX - boxWidth / 2
712
+ const maxX = centerX + boxWidth / 2
713
+ const minY = centerY - boxHeight / 2
714
+ const maxY = centerY + boxHeight / 2
715
+
716
+ return !(
717
+ maxX < bounds.minX ||
718
+ minX > bounds.maxX ||
719
+ maxY < bounds.minY ||
720
+ minY > bounds.maxY
721
+ )
722
+ }
723
+
724
+ /**
725
+ * Returns the visible top-layer pad shape code, including rounded-rect
726
+ * overrides from the optional extension block.
727
+ * @param {{ shapeTop?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null }} pad
728
+ * @returns {number}
729
+ */
730
+ static #resolvePadShape(pad) {
731
+ if (pad.hasRoundedRect && Number.isInteger(pad.roundedRectShapeTop)) {
732
+ return Number(pad.roundedRectShapeTop)
733
+ }
734
+ return Number(pad.shapeTop || 0)
735
+ }
736
+
737
+ /**
738
+ * Returns the corner radius for one visible pad ring.
739
+ * @param {{ shapeTop?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, cornerRadiusTop?: number | null }} pad
740
+ * @param {{ width: number, height: number }} size
741
+ * @returns {number}
742
+ */
743
+ static #resolvePadCornerRadius(pad, size) {
744
+ if (
745
+ pad.hasRoundedRect &&
746
+ Number.isFinite(pad.cornerRadiusTop) &&
747
+ Number(pad.cornerRadiusTop) > 0
748
+ ) {
749
+ return (
750
+ Math.min(size.width, size.height) *
751
+ (Number(pad.cornerRadiusTop) / 100)
752
+ )
753
+ }
754
+
755
+ if (PcbSvgRenderer.#resolvePadShape(pad) === 1) {
756
+ return Math.min(size.width, size.height) / 2
757
+ }
758
+
759
+ return 0
760
+ }
761
+
762
+ /**
763
+ * Returns true when one pad hole is a round-ended slot.
764
+ * @param {{ holeShape?: number | null, holeSlotLength?: number | null, holeDiameter?: number }} pad
765
+ * @returns {boolean}
766
+ */
767
+ static #isSlotHole(pad) {
768
+ return (
769
+ Number(pad.holeShape) === PcbSvgRenderer.#PAD_HOLE_SHAPE_SLOT &&
770
+ Number(pad.holeSlotLength || 0) > Number(pad.holeDiameter || 0)
771
+ )
772
+ }
773
+
774
+ /**
775
+ * Splits recovered copper primitives into the default top-facing surface
776
+ * view and de-emphasized buried layers.
777
+ * @param {{ layer?: string, segments: Array<Record<string, number | string>> }[]} polygons
778
+ * @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
779
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
780
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
781
+ * @returns {{ surface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }, subsurface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] } }}
782
+ */
783
+ static #splitCopperPrimitives(polygons, fills, tracks, arcs) {
784
+ const copperFills = fills.filter((fill) =>
785
+ PcbSvgRenderer.#isCopperLayerId(fill.layerId)
786
+ )
787
+ const copperTracks = tracks.filter((track) =>
788
+ PcbSvgRenderer.#isCopperLayerId(track.layerId)
789
+ )
790
+ const copperArcs = arcs.filter((arc) =>
791
+ PcbSvgRenderer.#isCopperLayerId(arc.layerId)
792
+ )
793
+ const surfaceTrackLayerCode =
794
+ PcbSvgRenderer.#resolveSurfaceLayerCode(copperTracks)
795
+ const surfaceFillLayerCode =
796
+ PcbSvgRenderer.#resolveSurfaceLayerCode(copperFills)
797
+ const surfaceArcLayerCode =
798
+ PcbSvgRenderer.#resolveSurfaceLayerCode(copperArcs)
799
+
800
+ return {
801
+ surface: {
802
+ polygons: polygons.filter((polygon) =>
803
+ PcbSvgRenderer.#isSurfacePolygon(polygon)
804
+ ),
805
+ fills: copperFills.filter(
806
+ (fill) => fill.layerCode === surfaceFillLayerCode
807
+ ),
808
+ tracks: copperTracks.filter(
809
+ (track) => track.layerCode === surfaceTrackLayerCode
810
+ ),
811
+ arcs: copperArcs.filter(
812
+ (arc) => arc.layerCode === surfaceArcLayerCode
813
+ )
814
+ },
815
+ subsurface: {
816
+ polygons: polygons.filter(
817
+ (polygon) => !PcbSvgRenderer.#isSurfacePolygon(polygon)
818
+ ),
819
+ fills: copperFills.filter(
820
+ (fill) => fill.layerCode !== surfaceFillLayerCode
821
+ ),
822
+ tracks: copperTracks.filter(
823
+ (track) => track.layerCode !== surfaceTrackLayerCode
824
+ ),
825
+ arcs: copperArcs.filter(
826
+ (arc) => arc.layerCode !== surfaceArcLayerCode
827
+ )
828
+ }
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Returns the default visible layer code from one primitive family.
834
+ * @param {{ layerCode?: number }[]} primitives
835
+ * @returns {number | null}
836
+ */
837
+ static #resolveSurfaceLayerCode(primitives) {
838
+ const layerCodes = primitives
839
+ .map((primitive) => primitive.layerCode)
840
+ .filter((layerCode) => Number.isFinite(layerCode))
841
+ return layerCodes.length ? Math.min(...layerCodes) : null
842
+ }
843
+
844
+ /**
845
+ * Returns true when one polygon belongs to the top-facing copper view.
846
+ * @param {{ layer?: string }} polygon
847
+ * @returns {boolean}
848
+ */
849
+ static #isSurfacePolygon(polygon) {
850
+ return (
851
+ String(polygon.layer || '')
852
+ .trim()
853
+ .toUpperCase() === 'TOP'
854
+ )
855
+ }
856
+
857
+ /**
858
+ * Returns true when one track intersects a component-local search box.
859
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} track
860
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
861
+ * @returns {boolean}
862
+ */
863
+ static #trackIntersectsBounds(track, bounds) {
864
+ const minX = Math.min(Number(track.x1), Number(track.x2))
865
+ const maxX = Math.max(Number(track.x1), Number(track.x2))
866
+ const minY = Math.min(Number(track.y1), Number(track.y2))
867
+ const maxY = Math.max(Number(track.y1), Number(track.y2))
868
+
869
+ return !(
870
+ maxX < bounds.minX ||
871
+ minX > bounds.maxX ||
872
+ maxY < bounds.minY ||
873
+ minY > bounds.maxY
874
+ )
875
+ }
876
+
877
+ /**
878
+ * Returns true when one fill intersects a component-local search box.
879
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} fill
880
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
881
+ * @returns {boolean}
882
+ */
883
+ static #fillIntersectsBounds(fill, bounds) {
884
+ const minX = Math.min(Number(fill.x1), Number(fill.x2))
885
+ const maxX = Math.max(Number(fill.x1), Number(fill.x2))
886
+ const minY = Math.min(Number(fill.y1), Number(fill.y2))
887
+ const maxY = Math.max(Number(fill.y1), Number(fill.y2))
888
+
889
+ return !(
890
+ maxX < bounds.minX ||
891
+ minX > bounds.maxX ||
892
+ maxY < bounds.minY ||
893
+ minY > bounds.maxY
894
+ )
895
+ }
896
+
897
+ /**
898
+ * Returns true when one decoded primitive layer belongs to the copper
899
+ * stack instead of a mechanical or annotation layer.
900
+ * @param {number | undefined} layerId
901
+ * @returns {boolean}
902
+ */
903
+ static #isCopperLayerId(layerId) {
904
+ return Number.isInteger(layerId) && layerId >= 1 && layerId <= 32
905
+ }
906
+ }