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,558 @@
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
+ import { SchematicColorResolver } from './SchematicColorResolver.mjs'
8
+
9
+ const { createSvgText, escapeHtml, formatNumber, projectSchematicY } =
10
+ SchematicSvgUtils
11
+
12
+ /**
13
+ * Renders schematic off-sheet ports and stacked port groups.
14
+ */
15
+ export class SchematicPortRenderer {
16
+ /**
17
+ * Builds schematic off-sheet port boxes, stacking adjacent rows into one
18
+ * shared outline when they use the same geometry and styling.
19
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[]} ports
20
+ * @param {number} sheetHeight
21
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
22
+ * @returns {string}
23
+ */
24
+ static buildMarkup(ports, sheetHeight, sheet) {
25
+ return SchematicPortRenderer.#groupPorts(ports)
26
+ .map((portGroup) =>
27
+ SchematicPortRenderer.#buildPortGroupMarkup(
28
+ portGroup,
29
+ sheetHeight,
30
+ sheet
31
+ )
32
+ )
33
+ .join('')
34
+ }
35
+
36
+ /**
37
+ * Builds one grouped schematic off-sheet port symbol.
38
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[]} portGroup
39
+ * @param {number} sheetHeight
40
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
41
+ * @returns {string}
42
+ */
43
+ static #buildPortGroupMarkup(portGroup, sheetHeight, sheet) {
44
+ const direction = portGroup[0]?.direction || 'right'
45
+ const baseTextOptions =
46
+ SchematicTypography.buildDefaultSchematicFontOptions(sheet)
47
+
48
+ if (SchematicPortRenderer.#isVerticalDirection(direction)) {
49
+ return (
50
+ '<g class="schematic-port">' +
51
+ portGroup
52
+ .map((port) =>
53
+ SchematicPortRenderer.#buildVerticalPortMarkup(
54
+ port,
55
+ sheetHeight,
56
+ baseTextOptions
57
+ )
58
+ )
59
+ .join('') +
60
+ '</g>'
61
+ )
62
+ }
63
+
64
+ const rows = [...portGroup]
65
+ .map((port) => ({
66
+ ...port,
67
+ projectedY:
68
+ projectSchematicY(sheetHeight, port.y) - port.height / 2
69
+ }))
70
+ .sort((left, right) => left.projectedY - right.projectedY)
71
+ const firstRow = rows[0]
72
+ const x = firstRow.x
73
+ const width = firstRow.width
74
+ const horizontalDirection = firstRow.direction || 'right'
75
+ const shape = firstRow.shape || 'single'
76
+ const tipDepth = Math.min(Math.max(firstRow.height - 2, 4), width / 2)
77
+ const outlineMarkup = rows
78
+ .map(
79
+ (row) =>
80
+ '<polygon points="' +
81
+ escapeHtml(
82
+ SchematicPortRenderer.#buildOutlinePoints(
83
+ row.x,
84
+ row.projectedY,
85
+ row.width,
86
+ row.height,
87
+ tipDepth,
88
+ horizontalDirection,
89
+ shape
90
+ )
91
+ ) +
92
+ '" fill="' +
93
+ escapeHtml(
94
+ SchematicColorResolver.resolveFill(
95
+ row.fill,
96
+ '--schematic-fill-color'
97
+ )
98
+ ) +
99
+ '" stroke="' +
100
+ escapeHtml(
101
+ SchematicColorResolver.resolveColor(
102
+ row.color,
103
+ '--schematic-port-color'
104
+ )
105
+ ) +
106
+ '" />'
107
+ )
108
+ .join('')
109
+ const labelMarkup = rows
110
+ .map((row) => {
111
+ const textOptions =
112
+ SchematicPortRenderer.#resolveLabelTextOptions(
113
+ row,
114
+ baseTextOptions
115
+ )
116
+
117
+ return createSvgText(
118
+ 'schematic-port-label',
119
+ SchematicPortRenderer.#resolveLabelX(
120
+ row.x,
121
+ row.width,
122
+ tipDepth,
123
+ horizontalDirection,
124
+ shape
125
+ ),
126
+ SchematicPortRenderer.#resolveLabelBaselineY(
127
+ row.projectedY,
128
+ row.height,
129
+ textOptions.fontSize
130
+ ),
131
+ row.name,
132
+ SchematicColorResolver.resolveColor(
133
+ row.color,
134
+ '--schematic-port-color'
135
+ ),
136
+ 'middle',
137
+ textOptions
138
+ )
139
+ })
140
+ .join('')
141
+
142
+ return (
143
+ '<g class="schematic-port">' + outlineMarkup + labelMarkup + '</g>'
144
+ )
145
+ }
146
+
147
+ /**
148
+ * Builds one style-4 vertical off-sheet port symbol.
149
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'up' | 'down' }} port
150
+ * @param {number} sheetHeight
151
+ * @param {{ fontSize: number, fontFamily: string, fontWeight: number }} baseTextOptions
152
+ * @returns {string}
153
+ */
154
+ static #buildVerticalPortMarkup(port, sheetHeight, baseTextOptions) {
155
+ const tipDepth = Math.min(Math.max(port.height - 2, 4), port.width / 2)
156
+ const projectedTopY = projectSchematicY(
157
+ sheetHeight,
158
+ port.y + port.width
159
+ )
160
+ const projectedBottomY = projectSchematicY(sheetHeight, port.y)
161
+ const textOptions = {
162
+ ...SchematicPortRenderer.#resolveLabelTextOptions(
163
+ port,
164
+ baseTextOptions
165
+ ),
166
+ rotation: -90
167
+ }
168
+
169
+ return (
170
+ '<polygon points="' +
171
+ escapeHtml(
172
+ SchematicPortRenderer.#buildVerticalOutlinePoints(
173
+ port.x,
174
+ projectedTopY,
175
+ projectedBottomY,
176
+ port.height,
177
+ tipDepth,
178
+ port.direction || 'up'
179
+ )
180
+ ) +
181
+ '" fill="' +
182
+ escapeHtml(
183
+ SchematicColorResolver.resolveFill(
184
+ port.fill,
185
+ '--schematic-fill-color'
186
+ )
187
+ ) +
188
+ '" stroke="' +
189
+ escapeHtml(
190
+ SchematicColorResolver.resolveColor(
191
+ port.color,
192
+ '--schematic-port-color'
193
+ )
194
+ ) +
195
+ '" />' +
196
+ createSvgText(
197
+ 'schematic-port-label',
198
+ SchematicPortRenderer.#resolveVerticalLabelX(
199
+ port.x,
200
+ textOptions.fontSize
201
+ ),
202
+ SchematicPortRenderer.#resolveVerticalLabelY(
203
+ projectedTopY,
204
+ projectedBottomY
205
+ ),
206
+ port.name,
207
+ SchematicColorResolver.resolveColor(
208
+ port.color,
209
+ '--schematic-port-color'
210
+ ),
211
+ 'middle',
212
+ textOptions
213
+ )
214
+ )
215
+ }
216
+
217
+ /**
218
+ * Builds the outer polygon for one off-sheet port group.
219
+ * @param {number} x
220
+ * @param {number} y
221
+ * @param {number} width
222
+ * @param {number} height
223
+ * @param {number} tipDepth
224
+ * @param {'left' | 'right'} direction
225
+ * @param {'single' | 'double' | 'plain'} shape
226
+ * @returns {string}
227
+ */
228
+ static #buildOutlinePoints(
229
+ x,
230
+ y,
231
+ width,
232
+ height,
233
+ tipDepth,
234
+ direction,
235
+ shape
236
+ ) {
237
+ if (shape === 'plain') {
238
+ return [
239
+ [x, y],
240
+ [x + width, y],
241
+ [x + width, y + height],
242
+ [x, y + height]
243
+ ]
244
+ .map(
245
+ ([pointX, pointY]) =>
246
+ formatNumber(pointX) + ',' + formatNumber(pointY)
247
+ )
248
+ .join(' ')
249
+ }
250
+
251
+ if (shape === 'double') {
252
+ return [
253
+ [x + tipDepth, y],
254
+ [x + width - tipDepth, y],
255
+ [x + width, y + height / 2],
256
+ [x + width - tipDepth, y + height],
257
+ [x + tipDepth, y + height],
258
+ [x, y + height / 2]
259
+ ]
260
+ .map(
261
+ ([pointX, pointY]) =>
262
+ formatNumber(pointX) + ',' + formatNumber(pointY)
263
+ )
264
+ .join(' ')
265
+ }
266
+
267
+ if (direction === 'left') {
268
+ return [
269
+ [x, y + height / 2],
270
+ [x + tipDepth, y],
271
+ [x + width, y],
272
+ [x + width, y + height],
273
+ [x + tipDepth, y + height]
274
+ ]
275
+ .map(
276
+ ([pointX, pointY]) =>
277
+ formatNumber(pointX) + ',' + formatNumber(pointY)
278
+ )
279
+ .join(' ')
280
+ }
281
+
282
+ return [
283
+ [x, y],
284
+ [x + width - tipDepth, y],
285
+ [x + width, y + height / 2],
286
+ [x + width - tipDepth, y + height],
287
+ [x, y + height]
288
+ ]
289
+ .map(
290
+ ([pointX, pointY]) =>
291
+ formatNumber(pointX) + ',' + formatNumber(pointY)
292
+ )
293
+ .join(' ')
294
+ }
295
+
296
+ /**
297
+ * Builds the polygon for one vertical style-4 off-sheet port.
298
+ * @param {number} x
299
+ * @param {number} projectedTopY
300
+ * @param {number} projectedBottomY
301
+ * @param {number} height
302
+ * @param {number} tipDepth
303
+ * @param {'up' | 'down'} direction
304
+ * @returns {string}
305
+ */
306
+ static #buildVerticalOutlinePoints(
307
+ x,
308
+ projectedTopY,
309
+ projectedBottomY,
310
+ height,
311
+ tipDepth,
312
+ direction
313
+ ) {
314
+ const halfWidth = height / 2
315
+
316
+ if (direction === 'down') {
317
+ return [
318
+ [x - halfWidth, projectedTopY],
319
+ [x + halfWidth, projectedTopY],
320
+ [x + halfWidth, projectedBottomY - tipDepth],
321
+ [x, projectedBottomY],
322
+ [x - halfWidth, projectedBottomY - tipDepth]
323
+ ]
324
+ .map(
325
+ ([pointX, pointY]) =>
326
+ formatNumber(pointX) + ',' + formatNumber(pointY)
327
+ )
328
+ .join(' ')
329
+ }
330
+
331
+ return [
332
+ [x, projectedTopY],
333
+ [x + halfWidth, projectedTopY + tipDepth],
334
+ [x + halfWidth, projectedBottomY],
335
+ [x - halfWidth, projectedBottomY],
336
+ [x - halfWidth, projectedTopY + tipDepth]
337
+ ]
338
+ .map(
339
+ ([pointX, pointY]) =>
340
+ formatNumber(pointX) + ',' + formatNumber(pointY)
341
+ )
342
+ .join(' ')
343
+ }
344
+
345
+ /**
346
+ * Centers one port label within the rectangular body ahead of the tip.
347
+ * @param {number} x
348
+ * @param {number} width
349
+ * @param {number} tipDepth
350
+ * @param {'left' | 'right'} direction
351
+ * @param {'single' | 'double' | 'plain'} shape
352
+ * @returns {number}
353
+ */
354
+ static #resolveLabelX(x, width, tipDepth, direction, shape) {
355
+ if (shape === 'plain' || shape === 'double') {
356
+ return x + width / 2
357
+ }
358
+
359
+ const bodyWidth = width - tipDepth
360
+
361
+ if (direction === 'left') {
362
+ return x + tipDepth + bodyWidth / 2
363
+ }
364
+
365
+ return x + bodyWidth / 2
366
+ }
367
+
368
+ /**
369
+ * Returns SVG text options scaled to fit one port box.
370
+ * @param {{ width: number, height: number, name: string }} row
371
+ * @param {{ fontSize: number, fontFamily: string, fontWeight: number }} baseTextOptions
372
+ * @returns {{ fontSize: number, fontFamily: string, fontWeight: number }}
373
+ */
374
+ static #resolveLabelTextOptions(row, baseTextOptions) {
375
+ return SchematicTypography.withViewerFontSize({
376
+ ...baseTextOptions,
377
+ fontSize: SchematicPortRenderer.#resolveLabelFontSize(
378
+ row.name,
379
+ row.width,
380
+ row.height,
381
+ baseTextOptions.fontSize
382
+ )
383
+ })
384
+ }
385
+
386
+ /**
387
+ * Centers one label vertically using its scaled font size.
388
+ * @param {number} projectedY
389
+ * @param {number} height
390
+ * @param {number} fontSize
391
+ * @returns {number}
392
+ */
393
+ static #resolveLabelBaselineY(projectedY, height, fontSize) {
394
+ return projectedY + height / 2 + fontSize * 0.36
395
+ }
396
+
397
+ /**
398
+ * Centers one rotated vertical label inside the port body.
399
+ * @param {number} x
400
+ * @param {number} fontSize
401
+ * @returns {number}
402
+ */
403
+ static #resolveVerticalLabelX(x, fontSize) {
404
+ return x + fontSize * 0.36
405
+ }
406
+
407
+ /**
408
+ * Returns the visual centerline for one rotated vertical label.
409
+ * @param {number} projectedTopY
410
+ * @param {number} projectedBottomY
411
+ * @returns {number}
412
+ */
413
+ static #resolveVerticalLabelY(projectedTopY, projectedBottomY) {
414
+ return (projectedTopY + projectedBottomY) / 2
415
+ }
416
+
417
+ /**
418
+ * Scales one port label to fit within the recovered polygon.
419
+ * @param {string} name
420
+ * @param {number} width
421
+ * @param {number} height
422
+ * @param {number} defaultFontSize
423
+ * @returns {number}
424
+ */
425
+ static #resolveLabelFontSize(name, width, height, defaultFontSize) {
426
+ const maxFontSizeFromHeight = Math.max(Number(height || 10) * 0.75, 4)
427
+ const horizontalPadding = 4
428
+ const availableWidth = Math.max(
429
+ Number(width || 40) - horizontalPadding,
430
+ 4
431
+ )
432
+ const estimatedWidthAtUnitSize =
433
+ SchematicPortRenderer.#estimateLabelWidth(name, 1)
434
+ const maxFontSizeFromWidth =
435
+ estimatedWidthAtUnitSize > 0
436
+ ? availableWidth / estimatedWidthAtUnitSize
437
+ : defaultFontSize
438
+
439
+ return Math.max(
440
+ Math.min(
441
+ Number(defaultFontSize || 10),
442
+ maxFontSizeFromHeight,
443
+ maxFontSizeFromWidth
444
+ ),
445
+ 4
446
+ )
447
+ }
448
+
449
+ /**
450
+ * Approximates rendered label width for the default serif schematic font.
451
+ * @param {string} text
452
+ * @param {number} fontSize
453
+ * @returns {number}
454
+ */
455
+ static #estimateLabelWidth(text, fontSize) {
456
+ let width = 0
457
+
458
+ for (const character of String(text || '')) {
459
+ width +=
460
+ SchematicPortRenderer.#measureCharacterWidth(character) *
461
+ fontSize
462
+ }
463
+
464
+ return width
465
+ }
466
+
467
+ /**
468
+ * Returns a rough Times New Roman width factor for one character.
469
+ * @param {string} character
470
+ * @returns {number}
471
+ */
472
+ static #measureCharacterWidth(character) {
473
+ if (/\s/.test(character)) return 0.32
474
+ if (/[.,;:!|]/.test(character)) return 0.24
475
+ if (/[()[\]{}]/.test(character)) return 0.32
476
+ if (/[-+/\\]/.test(character)) return 0.36
477
+ if (/[MW@#%&]/.test(character)) return 0.82
478
+ if (/[A-Z]/.test(character)) return 0.62
479
+ if (/[a-z0-9]/.test(character)) return 0.5
480
+ if (/[^ -~]/.test(character)) return 0.92
481
+
482
+ return 0.56
483
+ }
484
+
485
+ /**
486
+ * Groups vertically adjacent off-sheet ports that share the same geometry
487
+ * and styling so they render as one stacked symbol.
488
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[]} ports
489
+ * @returns {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[][]}
490
+ */
491
+ static #groupPorts(ports) {
492
+ const sortedPorts = [...ports].sort(
493
+ (left, right) =>
494
+ left.x - right.x ||
495
+ left.width - right.width ||
496
+ left.height - right.height ||
497
+ String(left.fill).localeCompare(String(right.fill)) ||
498
+ String(left.color).localeCompare(String(right.color)) ||
499
+ String(left.shape || '').localeCompare(
500
+ String(right.shape || '')
501
+ ) ||
502
+ left.y - right.y
503
+ )
504
+ const groups = []
505
+
506
+ for (const port of sortedPorts) {
507
+ const previousGroup = groups[groups.length - 1]
508
+
509
+ if (
510
+ previousGroup &&
511
+ SchematicPortRenderer.#canAppendPort(previousGroup, port)
512
+ ) {
513
+ previousGroup.push(port)
514
+ continue
515
+ }
516
+
517
+ groups.push([port])
518
+ }
519
+
520
+ return groups
521
+ }
522
+
523
+ /**
524
+ * Returns true when one port can extend an existing stacked-port group.
525
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[]} portGroup
526
+ * @param {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }} port
527
+ * @returns {boolean}
528
+ */
529
+ static #canAppendPort(portGroup, port) {
530
+ const firstPort = portGroup[0]
531
+ const previousPort = portGroup[portGroup.length - 1]
532
+ const direction = firstPort.direction || 'right'
533
+
534
+ if (SchematicPortRenderer.#isVerticalDirection(direction)) {
535
+ return false
536
+ }
537
+
538
+ return (
539
+ firstPort.x === port.x &&
540
+ firstPort.width === port.width &&
541
+ firstPort.height === port.height &&
542
+ firstPort.fill === port.fill &&
543
+ firstPort.color === port.color &&
544
+ (firstPort.shape || 'single') === (port.shape || 'single') &&
545
+ (firstPort.direction || 'right') === (port.direction || 'right') &&
546
+ Math.abs(port.y - previousPort.y - previousPort.height) <= 0.01
547
+ )
548
+ }
549
+
550
+ /**
551
+ * Returns true when a port direction uses the style-4 vertical geometry.
552
+ * @param {'left' | 'right' | 'up' | 'down'} direction
553
+ * @returns {boolean}
554
+ */
555
+ static #isVerticalDirection(direction) {
556
+ return direction === 'up' || direction === 'down'
557
+ }
558
+ }