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,184 @@
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 { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
+ import { SchematicTypography } from './SchematicTypography.mjs'
8
+
9
+ const { createSvgText, escapeHtml, formatNumber, projectSchematicY } =
10
+ SchematicSvgUtils
11
+
12
+ /**
13
+ * Renders normalized schematic directives into SVG markup.
14
+ */
15
+ export class SchematicDirectiveRenderer {
16
+ /**
17
+ * Builds directive markup for supported schematic directive primitives.
18
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number }[]} directives
19
+ * @param {number} sheetHeight
20
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
21
+ * @returns {string}
22
+ */
23
+ static buildMarkup(directives, sheetHeight, sheet) {
24
+ return directives
25
+ .map((directive) =>
26
+ SchematicDirectiveRenderer.#buildDirectiveMarkup(
27
+ directive,
28
+ sheetHeight,
29
+ sheet
30
+ )
31
+ )
32
+ .join('')
33
+ }
34
+
35
+ /**
36
+ * Builds one supported directive glyph.
37
+ * @param {{ x: number, y: number, color: string, name: string, orientation?: number }} directive
38
+ * @param {number} sheetHeight
39
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
40
+ * @returns {string}
41
+ */
42
+ static #buildDirectiveMarkup(directive, sheetHeight, sheet) {
43
+ switch (String(directive?.name || '').toUpperCase()) {
44
+ case 'DIFFPAIR':
45
+ return SchematicDirectiveRenderer.#buildDiffPairMarkup(
46
+ directive,
47
+ sheetHeight
48
+ )
49
+ case 'DIFFPAIRROUTING':
50
+ return SchematicDirectiveRenderer.#buildRouteMarkup(
51
+ directive,
52
+ sheetHeight,
53
+ sheet
54
+ )
55
+ default:
56
+ return ''
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Builds the labeled route-callout marker for one differential-pair
62
+ * routing directive.
63
+ * @param {{ x: number, y: number, color: string, orientation?: number }} directive
64
+ * @param {number} sheetHeight
65
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
66
+ * @returns {string}
67
+ */
68
+ static #buildRouteMarkup(directive, sheetHeight, sheet) {
69
+ const color = SchematicColorResolver.resolveColor(
70
+ directive.color,
71
+ '--schematic-alert-color'
72
+ )
73
+ const projectedY = projectSchematicY(sheetHeight, directive.y)
74
+ const verticalDirection =
75
+ Number(directive.orientation || 0) === 3 ? 1 : -1
76
+ const circleRadius = 7
77
+ const circleCenterY = projectedY + verticalDirection * 18
78
+ const leaderEndY = circleCenterY - verticalDirection * circleRadius
79
+ const labelOptions =
80
+ SchematicTypography.buildViewerSchematicFontOptions(sheet)
81
+ const infoOptions = {
82
+ ...labelOptions,
83
+ fontSize: Math.max(Number(labelOptions.fontSize || 9) - 1, 6),
84
+ fontWeight: 700
85
+ }
86
+ const labelY =
87
+ circleCenterY +
88
+ verticalDirection *
89
+ (circleRadius + Number(labelOptions.fontSize || 9))
90
+
91
+ return (
92
+ '<g class="schematic-directive schematic-directive--route">' +
93
+ '<line x1="' +
94
+ formatNumber(directive.x) +
95
+ '" y1="' +
96
+ formatNumber(projectedY) +
97
+ '" x2="' +
98
+ formatNumber(directive.x) +
99
+ '" y2="' +
100
+ formatNumber(leaderEndY) +
101
+ '" stroke="' +
102
+ escapeHtml(color) +
103
+ '" stroke-width="1" />' +
104
+ '<circle cx="' +
105
+ formatNumber(directive.x) +
106
+ '" cy="' +
107
+ formatNumber(circleCenterY) +
108
+ '" r="' +
109
+ formatNumber(circleRadius) +
110
+ '" fill="none" stroke="' +
111
+ escapeHtml(color) +
112
+ '" stroke-width="1" />' +
113
+ createSvgText(
114
+ 'schematic-directive-label',
115
+ directive.x,
116
+ labelY,
117
+ String(directive.name || ''),
118
+ color,
119
+ 'middle',
120
+ labelOptions
121
+ ) +
122
+ createSvgText(
123
+ 'schematic-directive-info',
124
+ directive.x,
125
+ circleCenterY + Number(infoOptions.fontSize || 8) * 0.34,
126
+ 'i',
127
+ color,
128
+ 'middle',
129
+ infoOptions
130
+ ) +
131
+ '</g>'
132
+ )
133
+ }
134
+
135
+ /**
136
+ * Builds the paired-trace differential-pair marker glyph.
137
+ * @param {{ x: number, y: number, color: string }} directive
138
+ * @param {number} sheetHeight
139
+ * @returns {string}
140
+ */
141
+ static #buildDiffPairMarkup(directive, sheetHeight) {
142
+ const color = SchematicColorResolver.resolveColor(
143
+ directive.color,
144
+ '--schematic-alert-color'
145
+ )
146
+ const centerY = projectSchematicY(sheetHeight, directive.y)
147
+ const topPoints = [
148
+ [directive.x - 10, centerY - 2],
149
+ [directive.x - 4, centerY - 2],
150
+ [directive.x, centerY - 6],
151
+ [directive.x + 4, centerY - 6],
152
+ [directive.x + 8, centerY - 2],
153
+ [directive.x + 14, centerY - 2]
154
+ ]
155
+ const bottomPoints = topPoints.map(([x, y]) => [x, y + 8])
156
+
157
+ return (
158
+ '<g class="schematic-directive schematic-directive--pair">' +
159
+ SchematicDirectiveRenderer.#buildPolyline(topPoints, color) +
160
+ SchematicDirectiveRenderer.#buildPolyline(bottomPoints, color) +
161
+ '</g>'
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Builds one open SVG polyline for a directive glyph.
167
+ * @param {number[][]} points
168
+ * @param {string} color
169
+ * @returns {string}
170
+ */
171
+ static #buildPolyline(points, color) {
172
+ return (
173
+ '<polyline points="' +
174
+ escapeHtml(
175
+ points
176
+ .map(([x, y]) => formatNumber(x) + ',' + formatNumber(y))
177
+ .join(' ')
178
+ ) +
179
+ '" fill="none" stroke="' +
180
+ escapeHtml(color) +
181
+ '" stroke-width="1" stroke-linejoin="round" stroke-linecap="round" />'
182
+ )
183
+ }
184
+ }
@@ -0,0 +1,135 @@
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 { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
+
8
+ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
+
10
+ /**
11
+ * Renders normalized schematic image placements.
12
+ */
13
+ export class SchematicImageRenderer {
14
+ /**
15
+ * Builds markup for embedded schematic images and unresolved placeholders.
16
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
17
+ * @param {number} sheetHeight
18
+ * @returns {string}
19
+ */
20
+ static buildMarkup(images, sheetHeight) {
21
+ return images
22
+ .map((image) =>
23
+ image.dataBase64 && image.mimeType
24
+ ? SchematicImageRenderer.#buildEmbeddedImageMarkup(
25
+ image,
26
+ sheetHeight
27
+ )
28
+ : SchematicImageRenderer.#buildPlaceholderMarkup(
29
+ image,
30
+ sheetHeight
31
+ )
32
+ )
33
+ .join('')
34
+ }
35
+
36
+ /**
37
+ * Builds one embedded SVG image node.
38
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType: string, dataBase64: string, keepAspect?: boolean }} image
39
+ * @param {number} sheetHeight
40
+ * @returns {string}
41
+ */
42
+ static #buildEmbeddedImageMarkup(image, sheetHeight) {
43
+ const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
44
+
45
+ return (
46
+ '<image class="schematic-embedded-image" x="' +
47
+ formatNumber(bounds.x) +
48
+ '" y="' +
49
+ formatNumber(bounds.y) +
50
+ '" width="' +
51
+ formatNumber(bounds.width) +
52
+ '" height="' +
53
+ formatNumber(bounds.height) +
54
+ '" preserveAspectRatio="' +
55
+ escapeHtml(image.keepAspect === false ? 'none' : 'xMidYMid meet') +
56
+ '" href="' +
57
+ escapeHtml(
58
+ 'data:' + image.mimeType + ';base64,' + image.dataBase64
59
+ ) +
60
+ '" />'
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Builds one placeholder frame when an image payload is unavailable.
66
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
67
+ * @param {number} sheetHeight
68
+ * @returns {string}
69
+ */
70
+ static #buildPlaceholderMarkup(image, sheetHeight) {
71
+ const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
72
+ const stroke = SchematicColorResolver.resolveColor(
73
+ '#c0c0c0',
74
+ '--schematic-note-border-color'
75
+ )
76
+
77
+ return (
78
+ '<g class="schematic-image-placeholder">' +
79
+ '<rect x="' +
80
+ formatNumber(bounds.x) +
81
+ '" y="' +
82
+ formatNumber(bounds.y) +
83
+ '" width="' +
84
+ formatNumber(bounds.width) +
85
+ '" height="' +
86
+ formatNumber(bounds.height) +
87
+ '" fill="none" stroke="' +
88
+ escapeHtml(stroke) +
89
+ '" stroke-width="1" />' +
90
+ '<line x1="' +
91
+ formatNumber(bounds.x) +
92
+ '" y1="' +
93
+ formatNumber(bounds.y) +
94
+ '" x2="' +
95
+ formatNumber(bounds.x + bounds.width) +
96
+ '" y2="' +
97
+ formatNumber(bounds.y + bounds.height) +
98
+ '" stroke="' +
99
+ escapeHtml(stroke) +
100
+ '" stroke-width="1" />' +
101
+ '<line x1="' +
102
+ formatNumber(bounds.x) +
103
+ '" y1="' +
104
+ formatNumber(bounds.y + bounds.height) +
105
+ '" x2="' +
106
+ formatNumber(bounds.x + bounds.width) +
107
+ '" y2="' +
108
+ formatNumber(bounds.y) +
109
+ '" stroke="' +
110
+ escapeHtml(stroke) +
111
+ '" stroke-width="1" />' +
112
+ '</g>'
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Resolves one image placement into SVG-space bounds.
118
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
119
+ * @param {number} sheetHeight
120
+ * @returns {{ x: number, y: number, width: number, height: number }}
121
+ */
122
+ static #resolveBounds(image, sheetHeight) {
123
+ const minX = Math.min(Number(image.x), Number(image.cornerX))
124
+ const maxX = Math.max(Number(image.x), Number(image.cornerX))
125
+ const minY = Math.min(Number(image.y), Number(image.cornerY))
126
+ const maxY = Math.max(Number(image.y), Number(image.cornerY))
127
+
128
+ return {
129
+ x: minX,
130
+ y: projectSchematicY(sheetHeight, maxY),
131
+ width: maxX - minX,
132
+ height: maxY - minY
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,381 @@
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 { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
+
8
+ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
+
10
+ /**
11
+ * Renders synthesized junction dots for connected schematic wire routes.
12
+ */
13
+ export class SchematicJunctionRenderer {
14
+ /**
15
+ * Builds junction-dot markup from connected wire linework.
16
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean }[]} lines
17
+ * @param {{ x: number, y: number }[]} crosses
18
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} [ports]
19
+ * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' }[]} [powerPorts]
20
+ * @param {number} sheetHeight
21
+ * @returns {string}
22
+ */
23
+ static buildMarkup(
24
+ lines,
25
+ crosses,
26
+ ports = [],
27
+ powerPorts = [],
28
+ sheetHeight
29
+ ) {
30
+ return SchematicJunctionRenderer.#resolveJunctions(
31
+ lines,
32
+ crosses,
33
+ ports,
34
+ powerPorts
35
+ )
36
+ .map(
37
+ (junction) =>
38
+ '<circle class="schematic-junction" cx="' +
39
+ formatNumber(junction.x) +
40
+ '" cy="' +
41
+ formatNumber(projectSchematicY(sheetHeight, junction.y)) +
42
+ '" r="2" fill="' +
43
+ escapeHtml(
44
+ SchematicColorResolver.resolveColor(
45
+ junction.color,
46
+ '--schematic-default-ink-color'
47
+ )
48
+ ) +
49
+ '" />'
50
+ )
51
+ .join('')
52
+ }
53
+
54
+ /**
55
+ * Resolves all wire-junction points that should display a connection dot.
56
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean }[]} lines
57
+ * @param {{ x: number, y: number }[]} crosses
58
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
59
+ * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' }[]} powerPorts
60
+ * @returns {{ x: number, y: number, color: string }[]}
61
+ */
62
+ static #resolveJunctions(lines, crosses, ports, powerPorts) {
63
+ const wireLines = lines.filter(
64
+ (line) => !line.ownerIndex && line.isBus !== true
65
+ )
66
+ const verticalPorts = ports.filter((port) =>
67
+ SchematicJunctionRenderer.#isVerticalPort(port)
68
+ )
69
+ const visiblePowerPorts = powerPorts.filter((powerPort) =>
70
+ SchematicJunctionRenderer.#isDrawablePowerPort(powerPort)
71
+ )
72
+
73
+ return SchematicJunctionRenderer.#collectCandidatePoints(
74
+ wireLines,
75
+ verticalPorts,
76
+ visiblePowerPorts
77
+ )
78
+ .filter(
79
+ (point) =>
80
+ !SchematicJunctionRenderer.#hasNearbyCross(point, crosses)
81
+ )
82
+ .flatMap((point) => {
83
+ const contributingLines = wireLines.filter((line) =>
84
+ SchematicJunctionRenderer.#lineContainsPoint(line, point)
85
+ )
86
+ const contributingPorts = verticalPorts.filter((port) =>
87
+ SchematicJunctionRenderer.#portContainsPoint(port, point)
88
+ )
89
+ const contributingPowerPorts = visiblePowerPorts.filter(
90
+ (powerPort) =>
91
+ SchematicJunctionRenderer.#powerPortContainsPoint(
92
+ powerPort,
93
+ point
94
+ )
95
+ )
96
+ const directions = new Set()
97
+
98
+ for (const line of contributingLines) {
99
+ SchematicJunctionRenderer.#appendDirections(
100
+ directions,
101
+ line,
102
+ point
103
+ )
104
+ }
105
+
106
+ for (const port of contributingPorts) {
107
+ SchematicJunctionRenderer.#appendPortDirections(
108
+ directions,
109
+ port
110
+ )
111
+ }
112
+
113
+ for (const powerPort of contributingPowerPorts) {
114
+ SchematicJunctionRenderer.#appendPowerPortDirections(
115
+ directions,
116
+ powerPort
117
+ )
118
+ }
119
+
120
+ if (directions.size < 3) {
121
+ return []
122
+ }
123
+
124
+ return [
125
+ {
126
+ x: point.x,
127
+ y: point.y,
128
+ color:
129
+ contributingLines[0]?.color ||
130
+ 'var(--schematic-default-ink-color)'
131
+ }
132
+ ]
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Collects all distinct wire endpoints as candidate junction points.
138
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
139
+ * @param {{ x: number, y: number, width: number, direction?: 'up' | 'down' }[]} ports
140
+ * @param {{ x: number, y: number }} powerPorts
141
+ * @returns {{ x: number, y: number }[]}
142
+ */
143
+ static #collectCandidatePoints(lines, ports, powerPorts) {
144
+ const candidates = new Map()
145
+
146
+ for (const line of lines) {
147
+ for (const point of [
148
+ { x: line.x1, y: line.y1 },
149
+ { x: line.x2, y: line.y2 }
150
+ ]) {
151
+ candidates.set(
152
+ SchematicJunctionRenderer.#pointKey(point),
153
+ point
154
+ )
155
+ }
156
+ }
157
+
158
+ for (const port of ports) {
159
+ const connectionPoint =
160
+ SchematicJunctionRenderer.#resolveVerticalPortConnectionPoint(
161
+ port
162
+ )
163
+
164
+ candidates.set(
165
+ SchematicJunctionRenderer.#pointKey(connectionPoint),
166
+ connectionPoint
167
+ )
168
+ }
169
+
170
+ for (const powerPort of powerPorts) {
171
+ candidates.set(
172
+ SchematicJunctionRenderer.#pointKey(powerPort),
173
+ powerPort
174
+ )
175
+ }
176
+
177
+ return [...candidates.values()]
178
+ }
179
+
180
+ /**
181
+ * Adds all directions a line contributes at one candidate point.
182
+ * @param {Set<string>} directions
183
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
184
+ * @param {{ x: number, y: number }} point
185
+ * @returns {void}
186
+ */
187
+ static #appendDirections(directions, line, point) {
188
+ if (line.x1 === line.x2 && line.x1 === point.x) {
189
+ const minY = Math.min(line.y1, line.y2)
190
+ const maxY = Math.max(line.y1, line.y2)
191
+
192
+ if (point.y > minY + 0.01) {
193
+ directions.add('south')
194
+ }
195
+
196
+ if (point.y < maxY - 0.01) {
197
+ directions.add('north')
198
+ }
199
+
200
+ return
201
+ }
202
+
203
+ if (line.y1 === line.y2 && line.y1 === point.y) {
204
+ const minX = Math.min(line.x1, line.x2)
205
+ const maxX = Math.max(line.x1, line.x2)
206
+
207
+ if (point.x > minX + 0.01) {
208
+ directions.add('west')
209
+ }
210
+
211
+ if (point.x < maxX - 0.01) {
212
+ directions.add('east')
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Adds the one branch direction contributed by a vertical off-sheet port.
219
+ * @param {Set<string>} directions
220
+ * @param {{ direction?: 'up' | 'down' }} port
221
+ * @returns {void}
222
+ */
223
+ static #appendPortDirections(directions, port) {
224
+ if (port.direction === 'up') {
225
+ directions.add('south')
226
+ return
227
+ }
228
+
229
+ if (port.direction === 'down') {
230
+ directions.add('north')
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Adds the branch direction contributed by one rendered power-port symbol.
236
+ * @param {Set<string>} directions
237
+ * @param {{ powerPortDirection?: 'up' | 'down' | 'left' | 'right' }} powerPort
238
+ * @returns {void}
239
+ */
240
+ static #appendPowerPortDirections(directions, powerPort) {
241
+ switch (powerPort.powerPortDirection) {
242
+ case 'up':
243
+ directions.add('north')
244
+ return
245
+ case 'down':
246
+ directions.add('south')
247
+ return
248
+ case 'left':
249
+ directions.add('west')
250
+ return
251
+ case 'right':
252
+ directions.add('east')
253
+ return
254
+ default:
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Returns true when one cross marker occupies the same point.
260
+ * @param {{ x: number, y: number }} point
261
+ * @param {{ x: number, y: number }[]} crosses
262
+ * @returns {boolean}
263
+ */
264
+ static #hasNearbyCross(point, crosses) {
265
+ return crosses.some(
266
+ (cross) =>
267
+ Math.abs(cross.x - point.x) <= 0.01 &&
268
+ Math.abs(cross.y - point.y) <= 0.01
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Returns true when one axis-aligned line passes through a candidate point.
274
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
275
+ * @param {{ x: number, y: number }} point
276
+ * @returns {boolean}
277
+ */
278
+ static #lineContainsPoint(line, point) {
279
+ if (line.x1 === line.x2 && line.x1 === point.x) {
280
+ return (
281
+ point.y >= Math.min(line.y1, line.y2) - 0.01 &&
282
+ point.y <= Math.max(line.y1, line.y2) + 0.01
283
+ )
284
+ }
285
+
286
+ if (line.y1 === line.y2 && line.y1 === point.y) {
287
+ return (
288
+ point.x >= Math.min(line.x1, line.x2) - 0.01 &&
289
+ point.x <= Math.max(line.x1, line.x2) + 0.01
290
+ )
291
+ }
292
+
293
+ return false
294
+ }
295
+
296
+ /**
297
+ * Returns true when one vertical off-sheet port attaches at the point.
298
+ * @param {{ x: number, y: number, width: number, direction?: 'up' | 'down' }} port
299
+ * @param {{ x: number, y: number }} point
300
+ * @returns {boolean}
301
+ */
302
+ static #portContainsPoint(port, point) {
303
+ if (!SchematicJunctionRenderer.#isVerticalPort(port)) {
304
+ return false
305
+ }
306
+
307
+ const connectionPoint =
308
+ SchematicJunctionRenderer.#resolveVerticalPortConnectionPoint(port)
309
+
310
+ return (
311
+ Math.abs(connectionPoint.x - point.x) <= 0.01 &&
312
+ Math.abs(connectionPoint.y - point.y) <= 0.01
313
+ )
314
+ }
315
+
316
+ /**
317
+ * Returns true when one rendered power port attaches at the candidate
318
+ * point.
319
+ * @param {{ x: number, y: number }} powerPort
320
+ * @param {{ x: number, y: number }} point
321
+ * @returns {boolean}
322
+ */
323
+ static #powerPortContainsPoint(powerPort, point) {
324
+ return (
325
+ Math.abs(powerPort.x - point.x) <= 0.01 &&
326
+ Math.abs(powerPort.y - point.y) <= 0.01
327
+ )
328
+ }
329
+
330
+ /**
331
+ * Returns the wire attachment point for one vertical off-sheet port.
332
+ * @param {{ x: number, y: number, width: number, direction?: 'up' | 'down' }} port
333
+ * @returns {{ x: number, y: number }}
334
+ */
335
+ static #resolveVerticalPortConnectionPoint(port) {
336
+ if (port.direction === 'down') {
337
+ return {
338
+ x: port.x,
339
+ y: port.y
340
+ }
341
+ }
342
+
343
+ return {
344
+ x: port.x,
345
+ y: port.y + port.width
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Returns true when a port uses the vertical style-4 geometry.
351
+ * @param {{ direction?: 'left' | 'right' | 'up' | 'down' }} port
352
+ * @returns {boolean}
353
+ */
354
+ static #isVerticalPort(port) {
355
+ return port.direction === 'up' || port.direction === 'down'
356
+ }
357
+
358
+ /**
359
+ * Returns true when one visible text-backed power port can contribute a
360
+ * junction branch.
361
+ * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' } | null | undefined} powerPort
362
+ * @returns {boolean}
363
+ */
364
+ static #isDrawablePowerPort(powerPort) {
365
+ return (
366
+ Boolean(powerPort) &&
367
+ Number.isFinite(Number(powerPort.x)) &&
368
+ Number.isFinite(Number(powerPort.y)) &&
369
+ typeof powerPort.powerPortDirection === 'string'
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Builds a stable map key for one point.
375
+ * @param {{ x: number, y: number }} point
376
+ * @returns {string}
377
+ */
378
+ static #pointKey(point) {
379
+ return String(point.x) + ':' + String(point.y)
380
+ }
381
+ }