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,661 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+ import { SchematicTypography } from './SchematicTypography.mjs'
7
+
8
+ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
+ const RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO = 0.35
10
+ const ISO_A_PORTRAIT_SHEETS = [
11
+ { label: 'A5', width: 583, height: 827 },
12
+ { label: 'A4', width: 827, height: 1169 },
13
+ { label: 'A3', width: 1169, height: 1654 },
14
+ { label: 'A2', width: 1654, height: 2339 },
15
+ { label: 'A1', width: 2339, height: 3307 },
16
+ { label: 'A0', width: 3307, height: 4681 }
17
+ ]
18
+
19
+ /**
20
+ * Computes schematic content transforms and clipping for normalized pages.
21
+ */
22
+ export class SchematicContentLayout {
23
+ /**
24
+ * Builds one deterministic clip-path identifier for one schematic SVG.
25
+ * @param {number} width
26
+ * @param {number} height
27
+ * @param {{ sheet?: { marginWidth?: number }, lines?: unknown[], texts?: unknown[], components?: unknown[], pins?: unknown[], regions?: unknown[] }} schematic
28
+ * @returns {string}
29
+ */
30
+ static buildClipId(width, height, schematic) {
31
+ return [
32
+ 'schematic-content-clip',
33
+ Math.round(Number(width || 0)),
34
+ Math.round(Number(height || 0)),
35
+ Math.round(Number(schematic?.sheet?.marginWidth || 20)),
36
+ (schematic?.lines || []).length,
37
+ (schematic?.texts || []).length,
38
+ (schematic?.components || []).length,
39
+ (schematic?.pins || []).length,
40
+ (schematic?.regions || []).length
41
+ ].join('-')
42
+ }
43
+
44
+ /**
45
+ * Builds the clip-path that confines schematic primitives to the sheet
46
+ * inner frame.
47
+ * @param {number} width
48
+ * @param {number} height
49
+ * @param {{ sheet?: { marginWidth?: number } }} schematic
50
+ * @param {string} clipId
51
+ * @returns {string}
52
+ */
53
+ static buildClipMarkup(width, height, schematic, clipId) {
54
+ const margin = Math.max(Number(schematic?.sheet?.marginWidth || 20), 10)
55
+
56
+ return (
57
+ '<defs><clipPath id="' +
58
+ escapeHtml(clipId) +
59
+ '"><rect x="' +
60
+ formatNumber(margin) +
61
+ '" y="' +
62
+ formatNumber(margin) +
63
+ '" width="' +
64
+ formatNumber(Math.max(width - margin * 2, 10)) +
65
+ '" height="' +
66
+ formatNumber(Math.max(height - margin * 2, 10)) +
67
+ '" /></clipPath></defs>'
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Builds one uniform SVG transform that scales recovered schematic
73
+ * primitives from their source inner frame into a larger normalized page.
74
+ * @param {number} width
75
+ * @param {number} height
76
+ * @param {{ sheet?: { marginWidth?: number, sourceWidth?: number, sourceHeight?: number, fonts?: Record<string, { size?: number }> }, lines?: { x1: number, y1: number, x2: number, y2: number }[], polygons?: { points: { x: number, y: number }[] }[], rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number }[], arcs?: { x: number, y: number, radius: number }[], texts?: { x: number, y: number }[], components?: { x: number, y: number }[], pins?: { x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[], ports?: { x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[], crosses?: { x: number, y: number, size?: number }[] }} schematic
77
+ * @returns {string}
78
+ */
79
+ static buildTransform(width, height, schematic) {
80
+ const sheet = schematic?.sheet
81
+ const margin = Math.max(Number(sheet?.marginWidth || 20), 10)
82
+ const bounds = SchematicContentLayout.#collectRenderedContentBounds(
83
+ schematic,
84
+ height
85
+ )
86
+ const contentPadding = SchematicContentLayout.#resolveContentPadding(
87
+ sheet,
88
+ margin
89
+ )
90
+ const footerReserve = SchematicContentLayout.#resolveFooterReserve(
91
+ sheet,
92
+ margin
93
+ )
94
+
95
+ if (!bounds) {
96
+ return ''
97
+ }
98
+
99
+ const normalizedTransform =
100
+ SchematicContentLayout.#buildNormalizedSheetTransform(
101
+ width,
102
+ height,
103
+ schematic,
104
+ sheet,
105
+ margin,
106
+ bounds,
107
+ contentPadding,
108
+ footerReserve
109
+ )
110
+ if (normalizedTransform) {
111
+ return normalizedTransform
112
+ }
113
+
114
+ return SchematicContentLayout.#buildSparseCustomSheetTransform(
115
+ width,
116
+ height,
117
+ sheet,
118
+ margin,
119
+ bounds,
120
+ contentPadding,
121
+ footerReserve
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Builds the existing normalized-sheet transform for pages that have been
127
+ * expanded beyond their original source size.
128
+ * @param {number} width
129
+ * @param {number} height
130
+ * @param {{ rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[] }} schematic
131
+ * @param {{ sourceWidth?: number, sourceHeight?: number } | undefined} sheet
132
+ * @param {number} margin
133
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
134
+ * @param {number} contentPadding
135
+ * @param {number} footerReserve
136
+ * @returns {string}
137
+ */
138
+ static #buildNormalizedSheetTransform(
139
+ width,
140
+ height,
141
+ schematic,
142
+ sheet,
143
+ margin,
144
+ bounds,
145
+ contentPadding,
146
+ footerReserve
147
+ ) {
148
+ const normalizedScaleLimit =
149
+ SchematicContentLayout.#buildNormalizedSheetScaleLimit(
150
+ width,
151
+ height,
152
+ sheet,
153
+ margin
154
+ )
155
+
156
+ if (!normalizedScaleLimit) {
157
+ return ''
158
+ }
159
+
160
+ const usedWidth = bounds.maxX - bounds.minX
161
+ const usedHeight = bounds.maxY - bounds.minY
162
+
163
+ if (usedWidth <= 0 || usedHeight <= 0) {
164
+ return ''
165
+ }
166
+
167
+ const topLimit = margin + contentPadding * 0.2
168
+ const bottomLimit = height - margin - footerReserve
169
+ const dominantAnchorBounds =
170
+ SchematicContentLayout.#resolveNormalizedAnchorBounds(
171
+ schematic,
172
+ height,
173
+ bounds
174
+ )
175
+ const anchorDeltaY = dominantAnchorBounds.minY - bounds.minY
176
+ const rightFitScaleLimit =
177
+ (width - margin - contentPadding - bounds.minX) / usedWidth
178
+ const bottomFitScaleLimit =
179
+ (bottomLimit - topLimit) / (bounds.maxY - dominantAnchorBounds.minY)
180
+ const scale = Math.min(
181
+ normalizedScaleLimit,
182
+ rightFitScaleLimit,
183
+ bottomFitScaleLimit
184
+ )
185
+
186
+ if (!Number.isFinite(scale) || scale <= 1) {
187
+ return ''
188
+ }
189
+
190
+ const targetMinY = topLimit - anchorDeltaY * scale
191
+
192
+ return (
193
+ ' transform="translate(' +
194
+ formatNumber(bounds.minX) +
195
+ ' ' +
196
+ formatNumber(targetMinY) +
197
+ ') scale(' +
198
+ formatNumber(scale) +
199
+ ') translate(' +
200
+ formatNumber(-bounds.minX) +
201
+ ' ' +
202
+ formatNumber(-bounds.minY) +
203
+ ')"'
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Builds a bottom-left-anchored scale transform for sparse custom sheets
209
+ * whose declared page is larger than the recovered content envelope.
210
+ * @param {number} width
211
+ * @param {number} height
212
+ * @param {{ paperSize?: string, sourceWidth?: number, sourceHeight?: number } | undefined} sheet
213
+ * @param {number} margin
214
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
215
+ * @param {number} contentPadding
216
+ * @param {number} footerReserve
217
+ * @returns {string}
218
+ */
219
+ static #buildSparseCustomSheetTransform(
220
+ width,
221
+ height,
222
+ sheet,
223
+ margin,
224
+ bounds,
225
+ contentPadding,
226
+ footerReserve
227
+ ) {
228
+ if (
229
+ sheet?.paperSize ||
230
+ width !== Number(sheet?.sourceWidth || 0) ||
231
+ height !== Number(sheet?.sourceHeight || 0)
232
+ ) {
233
+ return ''
234
+ }
235
+
236
+ const virtualSourceSheet =
237
+ SchematicContentLayout.#resolveVirtualStandardSourceSheet(
238
+ width,
239
+ height,
240
+ margin,
241
+ bounds
242
+ )
243
+
244
+ if (!virtualSourceSheet) {
245
+ return ''
246
+ }
247
+
248
+ const virtualInnerWidth = virtualSourceSheet.width - margin * 2
249
+ const scale = (width - margin * 2) / virtualInnerWidth
250
+
251
+ if (!Number.isFinite(scale) || scale <= 1) {
252
+ return ''
253
+ }
254
+
255
+ const pivotX = margin
256
+ const pivotY = height - margin
257
+ const projectedMinX = pivotX + (bounds.minX - pivotX) * scale
258
+ const projectedMaxX = pivotX + (bounds.maxX - pivotX) * scale
259
+ const projectedMinY = pivotY + (bounds.minY - pivotY) * scale
260
+ const projectedMaxY = pivotY + (bounds.maxY - pivotY) * scale
261
+ const topLimit = margin + contentPadding
262
+ const bottomLimit = height - margin - footerReserve
263
+ const rightLimit = width - margin
264
+
265
+ if (
266
+ projectedMinX < margin ||
267
+ projectedMaxX > rightLimit ||
268
+ projectedMinY < topLimit ||
269
+ projectedMaxY > bottomLimit
270
+ ) {
271
+ return ''
272
+ }
273
+
274
+ return (
275
+ ' transform="translate(' +
276
+ formatNumber(pivotX) +
277
+ ' ' +
278
+ formatNumber(pivotY) +
279
+ ') scale(' +
280
+ formatNumber(scale) +
281
+ ') translate(' +
282
+ formatNumber(-pivotX) +
283
+ ' ' +
284
+ formatNumber(-pivotY) +
285
+ ')"'
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Resolves the maximum sheet-wide scale implied by source and normalized
291
+ * page sizes.
292
+ * @param {number} width
293
+ * @param {number} height
294
+ * @param {{ sourceWidth?: number, sourceHeight?: number }} sheet
295
+ * @param {number} margin
296
+ * @returns {number}
297
+ */
298
+ static #buildNormalizedSheetScaleLimit(width, height, sheet, margin) {
299
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
300
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
301
+
302
+ if (
303
+ sourceWidth <= margin * 2 ||
304
+ sourceHeight <= margin * 2 ||
305
+ (width <= sourceWidth && height <= sourceHeight)
306
+ ) {
307
+ return 0
308
+ }
309
+
310
+ return Math.min(
311
+ (width - margin * 2) / (sourceWidth - margin * 2),
312
+ (height - margin * 2) / (sourceHeight - margin * 2)
313
+ )
314
+ }
315
+
316
+ /**
317
+ * Resolves a looser standard-page proxy for sparse custom sheets so the
318
+ * viewer can scale their content into the authored page without shrinking
319
+ * the sheet itself.
320
+ * @param {number} width
321
+ * @param {number} height
322
+ * @param {number} margin
323
+ * @param {{ minY: number, maxX: number }} bounds
324
+ * @returns {{ label: string, width: number, height: number } | null}
325
+ */
326
+ static #resolveVirtualStandardSourceSheet(width, height, margin, bounds) {
327
+ const requiredWidth = Math.max(Number(bounds.maxX || 0) + margin * 2, 0)
328
+ const requiredHeight = Math.max(
329
+ height - Number(bounds.minY || 0) + margin * 2,
330
+ 0
331
+ )
332
+ const landscape = requiredWidth >= requiredHeight
333
+ const candidates = ISO_A_PORTRAIT_SHEETS.map((sheet) => ({
334
+ label: sheet.label,
335
+ width: landscape ? sheet.height : sheet.width,
336
+ height: landscape ? sheet.width : sheet.height
337
+ }))
338
+ const matchingSheet =
339
+ candidates.find(
340
+ (sheet) =>
341
+ sheet.width >= requiredWidth &&
342
+ sheet.height >= requiredHeight
343
+ ) || null
344
+
345
+ if (!matchingSheet) {
346
+ return null
347
+ }
348
+
349
+ const widthSlackRatio =
350
+ (matchingSheet.width - requiredWidth) / requiredWidth
351
+ const heightSlackRatio =
352
+ (matchingSheet.height - requiredHeight) / requiredHeight
353
+
354
+ return widthSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
355
+ heightSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
356
+ matchingSheet.width < width
357
+ ? matchingSheet
358
+ : null
359
+ }
360
+
361
+ /**
362
+ * Resolves one conservative padding band so scaled content leaves room for
363
+ * visible labels and stroke caps inside the sheet frame.
364
+ * @param {{ fonts?: Record<string, { size?: number }> } | undefined} sheet
365
+ * @param {number} margin
366
+ * @returns {number}
367
+ */
368
+ static #resolveContentPadding(sheet, margin) {
369
+ const fontSizes = Object.values(sheet?.fonts || {}).map(
370
+ (font) =>
371
+ SchematicTypography.resolveViewerFontSize(
372
+ Number(font?.size || 0)
373
+ ) || 0
374
+ )
375
+ const maxViewerFontSize = Math.max(...fontSizes, 0)
376
+
377
+ return Math.max(maxViewerFontSize * 1.5, margin)
378
+ }
379
+
380
+ /**
381
+ * Reserves a small bottom band when the page footer/title block is shown
382
+ * so vertically scaled content sits in the main drawing area instead of
383
+ * visually sagging toward the footer.
384
+ * @param {{ titleBlockOn?: boolean } | undefined} sheet
385
+ * @param {number} margin
386
+ * @returns {number}
387
+ */
388
+ static #resolveFooterReserve(sheet, margin) {
389
+ if (!sheet?.titleBlockOn) {
390
+ return 0
391
+ }
392
+
393
+ return margin
394
+ }
395
+
396
+ /**
397
+ * Resolves the dominant rendered box that should guide normalized-sheet
398
+ * top anchoring when a large authored frame sits below smaller outliers.
399
+ * @param {{ rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[] }} schematic
400
+ * @param {number} sheetHeight
401
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
402
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
403
+ */
404
+ static #resolveNormalizedAnchorBounds(schematic, sheetHeight, bounds) {
405
+ const totalWidth = bounds.maxX - bounds.minX
406
+ const totalHeight = bounds.maxY - bounds.minY
407
+
408
+ if (totalWidth <= 0 || totalHeight <= 0) {
409
+ return bounds
410
+ }
411
+
412
+ const minimumWidth = totalWidth * 0.45
413
+ const minimumHeight = totalHeight * 0.2
414
+ const minimumArea = totalWidth * totalHeight * 0.12
415
+ const candidates = [
416
+ ...SchematicContentLayout.#collectRenderedBoxBounds(
417
+ schematic?.rectangles,
418
+ sheetHeight
419
+ ),
420
+ ...SchematicContentLayout.#collectRenderedBoxBounds(
421
+ schematic?.regions,
422
+ sheetHeight
423
+ )
424
+ ].filter((candidate) => {
425
+ const width = candidate.maxX - candidate.minX
426
+ const height = candidate.maxY - candidate.minY
427
+ const area = width * height
428
+
429
+ return (
430
+ candidate.minY > bounds.minY &&
431
+ width >= minimumWidth &&
432
+ height >= minimumHeight &&
433
+ area >= minimumArea
434
+ )
435
+ })
436
+
437
+ if (!candidates.length) {
438
+ return bounds
439
+ }
440
+
441
+ candidates.sort(
442
+ (left, right) =>
443
+ left.minY - right.minY ||
444
+ (right.maxX - right.minX) * (right.maxY - right.minY) -
445
+ (left.maxX - left.minX) * (left.maxY - left.minY)
446
+ )
447
+
448
+ return candidates[0]
449
+ }
450
+
451
+ /**
452
+ * Collects one approximate visible content envelope in rendered SVG
453
+ * coordinates.
454
+ * @param {{ lines?: { x1: number, y1: number, x2: number, y2: number }[], polygons?: { points: { x: number, y: number }[] }[], rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number }[], arcs?: { x: number, y: number, radius: number }[], texts?: { x: number, y: number }[], components?: { x: number, y: number }[], pins?: { x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[], ports?: { x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[], crosses?: { x: number, y: number, size?: number }[] }} schematic
455
+ * @param {number} sheetHeight
456
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
457
+ */
458
+ static #collectRenderedContentBounds(schematic, sheetHeight) {
459
+ const coordinates = []
460
+
461
+ for (const line of schematic?.lines || []) {
462
+ coordinates.push(
463
+ [line.x1, projectSchematicY(sheetHeight, line.y1)],
464
+ [line.x2, projectSchematicY(sheetHeight, line.y2)]
465
+ )
466
+ }
467
+
468
+ for (const polygon of schematic?.polygons || []) {
469
+ for (const point of polygon.points || []) {
470
+ coordinates.push([
471
+ point.x,
472
+ projectSchematicY(sheetHeight, point.y)
473
+ ])
474
+ }
475
+ }
476
+
477
+ for (const rectangle of schematic?.rectangles || []) {
478
+ coordinates.push(
479
+ [
480
+ rectangle.x,
481
+ projectSchematicY(
482
+ sheetHeight,
483
+ rectangle.y + rectangle.height
484
+ )
485
+ ],
486
+ [
487
+ rectangle.x + rectangle.width,
488
+ projectSchematicY(sheetHeight, rectangle.y)
489
+ ]
490
+ )
491
+ }
492
+
493
+ for (const region of schematic?.regions || []) {
494
+ coordinates.push(
495
+ [
496
+ region.x,
497
+ projectSchematicY(sheetHeight, region.y + region.height)
498
+ ],
499
+ [
500
+ region.x + region.width,
501
+ projectSchematicY(sheetHeight, region.y)
502
+ ]
503
+ )
504
+ }
505
+
506
+ for (const ellipse of schematic?.ellipses || []) {
507
+ coordinates.push(
508
+ [
509
+ ellipse.x - Math.max(Number(ellipse.radiusX || 0), 0),
510
+ projectSchematicY(
511
+ sheetHeight,
512
+ ellipse.y + Math.max(Number(ellipse.radiusY || 0), 0)
513
+ )
514
+ ],
515
+ [
516
+ ellipse.x + Math.max(Number(ellipse.radiusX || 0), 0),
517
+ projectSchematicY(
518
+ sheetHeight,
519
+ ellipse.y - Math.max(Number(ellipse.radiusY || 0), 0)
520
+ )
521
+ ]
522
+ )
523
+ }
524
+
525
+ for (const arc of schematic?.arcs || []) {
526
+ const radius = Math.max(Number(arc.radius || 0), 0)
527
+ coordinates.push(
528
+ [
529
+ arc.x - radius,
530
+ projectSchematicY(sheetHeight, arc.y + radius)
531
+ ],
532
+ [arc.x + radius, projectSchematicY(sheetHeight, arc.y - radius)]
533
+ )
534
+ }
535
+
536
+ for (const text of schematic?.texts || []) {
537
+ coordinates.push([text.x, projectSchematicY(sheetHeight, text.y)])
538
+ }
539
+
540
+ for (const component of schematic?.components || []) {
541
+ coordinates.push([
542
+ component.x,
543
+ projectSchematicY(sheetHeight, component.y)
544
+ ])
545
+ }
546
+
547
+ for (const pin of schematic?.pins || []) {
548
+ const geometry = SchematicContentLayout.#projectPinGeometry(pin)
549
+ if (!geometry) {
550
+ continue
551
+ }
552
+
553
+ coordinates.push(
554
+ [
555
+ geometry.bodyX,
556
+ projectSchematicY(sheetHeight, geometry.bodyY)
557
+ ],
558
+ [
559
+ geometry.outerX,
560
+ projectSchematicY(sheetHeight, geometry.outerY)
561
+ ]
562
+ )
563
+ }
564
+
565
+ for (const port of schematic?.ports || []) {
566
+ if (port.direction === 'up' || port.direction === 'down') {
567
+ const halfWidth = Number(port.height || 0) / 2
568
+ coordinates.push(
569
+ [
570
+ port.x - halfWidth,
571
+ projectSchematicY(sheetHeight, port.y + port.width)
572
+ ],
573
+ [port.x + halfWidth, projectSchematicY(sheetHeight, port.y)]
574
+ )
575
+ continue
576
+ }
577
+
578
+ coordinates.push(
579
+ [port.x, projectSchematicY(sheetHeight, port.y + port.height)],
580
+ [port.x + port.width, projectSchematicY(sheetHeight, port.y)]
581
+ )
582
+ }
583
+
584
+ for (const cross of schematic?.crosses || []) {
585
+ const half = Math.max(Number(cross.size || 6), 4) / 2
586
+ coordinates.push(
587
+ [
588
+ cross.x - half,
589
+ projectSchematicY(sheetHeight, cross.y) - half
590
+ ],
591
+ [cross.x + half, projectSchematicY(sheetHeight, cross.y) + half]
592
+ )
593
+ }
594
+
595
+ if (!coordinates.length) {
596
+ return null
597
+ }
598
+
599
+ return {
600
+ minX: Math.min(...coordinates.map(([x]) => x)),
601
+ minY: Math.min(...coordinates.map(([, y]) => y)),
602
+ maxX: Math.max(...coordinates.map(([x]) => x)),
603
+ maxY: Math.max(...coordinates.map(([, y]) => y))
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Projects authored rectangles or regions into rendered SVG bounds.
609
+ * @param {{ x: number, y: number, width: number, height: number }[] | undefined} boxes
610
+ * @param {number} sheetHeight
611
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }[]}
612
+ */
613
+ static #collectRenderedBoxBounds(boxes, sheetHeight) {
614
+ return (boxes || []).map((box) => ({
615
+ minX: box.x,
616
+ minY: projectSchematicY(sheetHeight, box.y + box.height),
617
+ maxX: box.x + box.width,
618
+ maxY: projectSchematicY(sheetHeight, box.y)
619
+ }))
620
+ }
621
+
622
+ /**
623
+ * Computes the inner endpoint for one schematic pin stub.
624
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
625
+ * @returns {{ bodyX: number, bodyY: number, outerX: number, outerY: number } | null}
626
+ */
627
+ static #projectPinGeometry(pin) {
628
+ switch (pin.orientation) {
629
+ case 'left':
630
+ return {
631
+ bodyX: pin.x,
632
+ bodyY: pin.y,
633
+ outerX: pin.x - pin.length,
634
+ outerY: pin.y
635
+ }
636
+ case 'right':
637
+ return {
638
+ bodyX: pin.x,
639
+ bodyY: pin.y,
640
+ outerX: pin.x + pin.length,
641
+ outerY: pin.y
642
+ }
643
+ case 'top':
644
+ return {
645
+ bodyX: pin.x,
646
+ bodyY: pin.y,
647
+ outerX: pin.x,
648
+ outerY: pin.y + pin.length
649
+ }
650
+ case 'bottom':
651
+ return {
652
+ bodyX: pin.x,
653
+ bodyY: pin.y,
654
+ outerX: pin.x,
655
+ outerY: pin.y - pin.length
656
+ }
657
+ default:
658
+ return null
659
+ }
660
+ }
661
+ }