@tscircuit/schematic-trace-solver 0.0.149 → 0.0.151

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 (162) hide show
  1. package/.agents/skills/schematic-trace-visualization/SKILL.md +7 -2
  2. package/README.md +6 -4
  3. package/dist/index.d.ts +64 -3
  4. package/dist/index.js +383 -66
  5. package/lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver.ts +123 -84
  6. package/lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver.ts +17 -1
  7. package/lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/SingleNetLabelPlacementSolver.ts +2 -0
  8. package/lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/solvePortOnlyPin.ts +2 -0
  9. package/lib/solvers/NetLabelToTraceSolver/NetLabelToTraceSolver.ts +338 -0
  10. package/lib/solvers/NetLabelTraceRecovery/getTraceRecoveryConnectivityMaps.ts +33 -0
  11. package/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +15 -0
  12. package/lib/types/InputProblem.ts +27 -1
  13. package/package.json +8 -1
  14. package/tests/bug-reports/bug-report-20260706T213649Z/__snapshots__/bug-report-20260706T213649Z.snap.svg +751 -5
  15. package/tests/bug-reports/bug-report-20260706T220324Z/__snapshots__/bug-report-20260706T220324Z.snap.svg +660 -5
  16. package/tests/bug-reports/bug-report-20260707T020342Z/__snapshots__/bug-report-20260707T020342Z.snap.svg +44 -5
  17. package/tests/bug-reports/bug-report-20260707T092615Z/__snapshots__/bug-report-20260707T092615Z.snap.svg +394 -5
  18. package/tests/bug-reports/bug-report-20260707T134549Z/__snapshots__/bug-report-20260707T134549Z.snap.svg +170 -5
  19. package/tests/bug-reports/bug-report-20260707T134722Z/__snapshots__/bug-report-20260707T134722Z.snap.svg +79 -5
  20. package/tests/bug-reports/bug-report-20260707T140410Z/__snapshots__/bug-report-20260707T140410Z.snap.svg +219 -5
  21. package/tests/bug-reports/bug-report-20260707T141025Z/__snapshots__/bug-report-20260707T141025Z.snap.svg +65 -5
  22. package/tests/bug-reports/bug-report-20260707T141421Z/__snapshots__/bug-report-20260707T141421Z.snap.svg +142 -5
  23. package/tests/bug-reports/bug-report-20260707T230831Z/__snapshots__/bug-report-20260707T230831Z.snap.svg +100 -5
  24. package/tests/bug-reports/bug-report-20260708T053736Z/__snapshots__/bug-report-20260708T053736Z.snap.svg +23 -5
  25. package/tests/bug-reports/bug-report-20260708T055430Z/__snapshots__/bug-report-20260708T055430Z.snap.svg +58 -5
  26. package/tests/bug-reports/bug-report-20260708T095725Z/__snapshots__/bug-report-20260708T095725Z.snap.svg +86 -5
  27. package/tests/bug-reports/bug-report-20260716T144856Z/__snapshots__/bug-report-20260716T144856Z.snap.svg +226 -5
  28. package/tests/bug-reports/bug-report-20260717T022934Z/__snapshots__/bug-report-20260717T022934Z.snap.svg +191 -5
  29. package/tests/bug-reports/bug-report-20260717T031704Z/__snapshots__/bug-report-20260717T031704Z.snap.svg +30 -5
  30. package/tests/bug-reports/bug-report-20260717T042845Z/__snapshots__/bug-report-20260717T042845Z.snap.svg +37 -5
  31. package/tests/bug-reports/bug-report-20260721T221026Z/__snapshots__/bug-report-20260721T221026Z.snap.svg +580 -2
  32. package/tests/bug-reports/bug-report-20260724T175257Z/__snapshots__/bug-report-20260724T175257Z.snap.svg +44 -5
  33. package/tests/bug-reports/bug-report-20260728T144234Z/__snapshots__/bug-report-20260728T144234Z.snap.svg +107 -5
  34. package/tests/bug-reports/bug-report-20260728T225606Z/__snapshots__/bug-report-20260728T225606Z.snap.svg +114 -5
  35. package/tests/bug-reports/bug-report-20260730T061837Z/__snapshots__/bug-report-20260730T061837Z.snap.svg +121 -5
  36. package/tests/bug-reports/bug-report-20260731T052229Z/__snapshots__/bug-report-20260731T052229Z.snap.svg +51 -5
  37. package/tests/bug-reports/bug-report-20260804T095800Z/__snapshots__/bug-report-20260804T095800Z.snap.svg +212 -5
  38. package/tests/bug-reports/bug-report-20260804T171919Z/__snapshots__/bug-report-20260804T171919Z.snap.svg +569 -5
  39. package/tests/bug-reports/bug-report-20260804T225912Z/__snapshots__/bug-report-20260804T225912Z.snap.svg +37 -5
  40. package/tests/bug-reports/bug-report-20260805T061316Z/__snapshots__/bug-report-20260805T061316Z.snap.svg +289 -5
  41. package/tests/bug-reports/bug-report-20260806T061548Z/__snapshots__/bug-report-20260806T061548Z.snap.svg +289 -5
  42. package/tests/bug-reports/bug-report-20260806T093501Z/__snapshots__/bug-report-20260806T093501Z.snap.svg +149 -5
  43. package/tests/bug-reports/bug-report-20260811T075142Z/__snapshots__/bug-report-20260811T075142Z.snap.svg +93 -5
  44. package/tests/bug-reports/bug-report-20260815T073240Z/__snapshots__/bug-report-20260815T073240Z.snap.svg +79 -5
  45. package/tests/bug-reports/bug-report-20260819T091818Z/__snapshots__/bug-report-20260819T091818Z.snap.svg +244 -2
  46. package/tests/examples/__snapshots__/example01.snap.svg +44 -5
  47. package/tests/examples/__snapshots__/example02.snap.svg +51 -5
  48. package/tests/examples/__snapshots__/example03.snap.svg +86 -5
  49. package/tests/examples/__snapshots__/example04.snap.svg +30 -5
  50. package/tests/examples/__snapshots__/example05.snap.svg +30 -5
  51. package/tests/examples/__snapshots__/example06.snap.svg +30 -5
  52. package/tests/examples/__snapshots__/example07.snap.svg +58 -5
  53. package/tests/examples/__snapshots__/example08.snap.svg +37 -5
  54. package/tests/examples/__snapshots__/example09.snap.svg +149 -5
  55. package/tests/examples/__snapshots__/example10.snap.svg +65 -5
  56. package/tests/examples/__snapshots__/example11.snap.svg +51 -5
  57. package/tests/examples/__snapshots__/example12.snap.svg +51 -5
  58. package/tests/examples/__snapshots__/example13.snap.svg +72 -5
  59. package/tests/examples/__snapshots__/example14.snap.svg +79 -5
  60. package/tests/examples/__snapshots__/example15.snap.svg +86 -5
  61. package/tests/examples/__snapshots__/example16.snap.svg +51 -5
  62. package/tests/examples/__snapshots__/example17.snap.svg +51 -5
  63. package/tests/examples/__snapshots__/example18.snap.svg +58 -5
  64. package/tests/examples/__snapshots__/example19.snap.svg +44 -5
  65. package/tests/examples/__snapshots__/example20.snap.svg +44 -5
  66. package/tests/examples/__snapshots__/example21.snap.svg +72 -5
  67. package/tests/examples/__snapshots__/example22.snap.svg +51 -5
  68. package/tests/examples/__snapshots__/example23.snap.svg +51 -5
  69. package/tests/examples/__snapshots__/example24.snap.svg +51 -5
  70. package/tests/examples/__snapshots__/example25.snap.svg +86 -5
  71. package/tests/examples/__snapshots__/example26.snap.svg +51 -5
  72. package/tests/examples/__snapshots__/example27.snap.svg +72 -5
  73. package/tests/examples/__snapshots__/example28.snap.svg +44 -5
  74. package/tests/examples/__snapshots__/example29.snap.svg +324 -5
  75. package/tests/examples/__snapshots__/example30.snap.svg +107 -5
  76. package/tests/examples/__snapshots__/example31.snap.svg +37 -5
  77. package/tests/examples/__snapshots__/example32.snap.svg +149 -5
  78. package/tests/examples/__snapshots__/example33.snap.svg +93 -5
  79. package/tests/examples/__snapshots__/example34.snap.svg +51 -5
  80. package/tests/examples/__snapshots__/example35.snap.svg +58 -5
  81. package/tests/examples/__snapshots__/example36.snap.svg +44 -5
  82. package/tests/examples/__snapshots__/example37.snap.svg +72 -5
  83. package/tests/examples/__snapshots__/example38.snap.svg +30 -5
  84. package/tests/examples/__snapshots__/example39.snap.svg +37 -5
  85. package/tests/examples/__snapshots__/example40.snap.svg +100 -5
  86. package/tests/examples/__snapshots__/example41.snap.svg +44 -5
  87. package/tests/examples/__snapshots__/example42.snap.svg +65 -5
  88. package/tests/examples/__snapshots__/example43.snap.svg +58 -5
  89. package/tests/examples/__snapshots__/example44.snap.svg +107 -5
  90. package/tests/examples/__snapshots__/example45.snap.svg +51 -5
  91. package/tests/examples/__snapshots__/example46.snap.svg +93 -5
  92. package/tests/examples/__snapshots__/example47.snap.svg +93 -5
  93. package/tests/examples/__snapshots__/example48.snap.svg +65 -5
  94. package/tests/examples/__snapshots__/example49.snap.svg +177 -5
  95. package/tests/examples/__snapshots__/example50.snap.svg +93 -5
  96. package/tests/examples/__snapshots__/example51.snap.svg +594 -21
  97. package/tests/fixtures/convertSolverOutputToCircuitJson.test.ts +219 -0
  98. package/tests/fixtures/convertSolverOutputToCircuitJson.ts +1058 -0
  99. package/tests/fixtures/matcher.ts +45 -8
  100. package/tests/repros/__snapshots__/board-1273-trace-overlap-cycle.snap.svg +762 -2
  101. package/tests/repros/__snapshots__/board-15984-same-net-junction.snap.svg +111 -2
  102. package/tests/repros/__snapshots__/board1096-usb-label-overlap-iteration.snap.svg +170 -5
  103. package/tests/repros/__snapshots__/bugreport-001-gnd-overlap.snap.svg +65 -5
  104. package/tests/repros/__snapshots__/manufacturePartNumber-text-box.snap.svg +30 -5
  105. package/tests/repros/__snapshots__/netlabel-connector-through-rail-label.snap.svg +79 -5
  106. package/tests/repros/__snapshots__/repro-atmega328p-fault-pullup.snap.svg +65 -5
  107. package/tests/repros/__snapshots__/repro-atmega328p-missing-gnd-netlabel.snap.svg +149 -5
  108. package/tests/repros/__snapshots__/repro-board-589-regulator-section.snap.svg +55 -2
  109. package/tests/repros/__snapshots__/repro-board-648-esp12f-section.snap.svg +160 -2
  110. package/tests/repros/__snapshots__/repro-bq24074-battery-charger.snap.svg +135 -5
  111. package/tests/repros/__snapshots__/repro-bq25895-cross-net-trace-overlap.snap.svg +69 -2
  112. package/tests/repros/__snapshots__/repro-cc2340r5.snap.svg +86 -5
  113. package/tests/repros/__snapshots__/repro-core-555-power-cross-net-overlap.snap.svg +44 -5
  114. package/tests/repros/__snapshots__/repro-core-subcircuit-missing-ground.snap.svg +44 -5
  115. package/tests/repros/__snapshots__/repro-core-tb67s579ftg-inline-label-routing.snap.svg +30 -5
  116. package/tests/repros/__snapshots__/repro-example35-minimize-trace-crossing.snap.svg +335 -2
  117. package/tests/repros/__snapshots__/repro-ground-label-on-lower-rail-end.snap.svg +48 -2
  118. package/tests/repros/__snapshots__/repro-ina237-current-monitor.snap.svg +149 -5
  119. package/tests/repros/__snapshots__/repro-missing-trace-netlabel.snap.svg +72 -5
  120. package/tests/repros/__snapshots__/repro-netlabel-collision-687.snap.svg +100 -5
  121. package/tests/repros/__snapshots__/repro-netlabel-overlap-trace.snap.svg +51 -5
  122. package/tests/repros/__snapshots__/repro-nrf52810-clock-routing.snap.svg +90 -2
  123. package/tests/repros/__snapshots__/repro-power-group-rail-labels.snap.svg +37 -5
  124. package/tests/repros/__snapshots__/repro-rectifier-trace-overlap.snap.svg +44 -5
  125. package/tests/repros/__snapshots__/repro-rp2040-gamepad-trace-alignment.snap.svg +237 -2
  126. package/tests/repros/__snapshots__/repro-rp2040-usb-cc2-ground-overlap.snap.svg +72 -5
  127. package/tests/repros/__snapshots__/repro-rp2040-v3v3-label-placement.snap.svg +51 -5
  128. package/tests/repros/__snapshots__/repro-rp2040-zero-crystal-fallback-netlabels.snap.svg +30 -5
  129. package/tests/repros/__snapshots__/repro-ti-power-output-section.snap.svg +79 -5
  130. package/tests/repros/__snapshots__/repro-tida010076-j4-ground-bus.snap.svg +27 -2
  131. package/tests/repros/__snapshots__/repro-tida010076-page02.snap.svg +1150 -5
  132. package/tests/repros/__snapshots__/repro-tps61222-trace-intersection.snap.svg +51 -5
  133. package/tests/repros/__snapshots__/repro-type-c-16pin-self-connection.snap.svg +30 -5
  134. package/tests/repros/__snapshots__/repro-type-c-16pin-to-resistor.snap.svg +30 -5
  135. package/tests/repros/__snapshots__/repro-type-c-vbus-led-ground-label.snap.svg +44 -5
  136. package/tests/repros/__snapshots__/repro-usb-power-vbus-label-detour.snap.svg +685 -2
  137. package/tests/repros/__snapshots__/repro-vcc-pin1-detour.snap.svg +27 -2
  138. package/tests/repros/__snapshots__/repro129-host-custom-symbol-passives.snap.svg +51 -5
  139. package/tests/repros/__snapshots__/repro130-bq27441-fuel-gauge-trace-through-c1.snap.svg +100 -5
  140. package/tests/repros/__snapshots__/repro161-power-section-autolayout.snap.svg +107 -5
  141. package/tests/repros/__snapshots__/repro47-endpoint-obstacle-detour.snap.svg +51 -5
  142. package/tests/repros/__snapshots__/repro48-555-timer-vcc-rail-label.snap.svg +104 -2
  143. package/tests/repros/__snapshots__/repro5-escape-padded-text-obstacles.snap.svg +30 -5
  144. package/tests/repros/__snapshots__/repro51-overlap-junction-crossing.snap.svg +65 -5
  145. package/tests/repros/__snapshots__/repro84-stacked-gnd-label-orientation.snap.svg +27 -2
  146. package/tests/repros/__snapshots__/small-variant-resistor-facing-direction.snap.svg +58 -5
  147. package/tests/repros/__snapshots__/trace-overlap-box-resistor.snap.svg +37 -5
  148. package/tests/solvers/InlineNetLabelSolver/__snapshots__/fallback-net-label-width.snap.svg +25 -7
  149. package/tests/solvers/InlineNetLabelSolver/__snapshots__/inline-net-label-anchored-label-clearance.snap.svg +34 -2
  150. package/tests/solvers/InlineNetLabelSolver/__snapshots__/inline-net-label-port-stub.snap.svg +23 -5
  151. package/tests/solvers/InlineNetLabelSolver/__snapshots__/inline-net-label-two-pin-net.snap.svg +23 -5
  152. package/tests/solvers/InlineNetLabelSolver/__snapshots__/inline-net-label01.snap.svg +23 -5
  153. package/tests/solvers/InlineNetLabelSolver/__snapshots__/inline-net-label02.snap.svg +23 -5
  154. package/tests/solvers/InlineNetLabelSolver/__snapshots__/terminal-stub-trace-clearance.snap.svg +23 -5
  155. package/tests/solvers/InlineNetLabelSolver/fallback-net-label-width.test.ts +10 -1
  156. package/tests/solvers/SchematicTraceSingleLineSolver2/__snapshots__/SchematicTraceSingleLineSolver2_01-example17-d1_1-u1_1.snap.svg +23 -5
  157. package/tests/solvers/SchematicTraceSingleLineSolver2/__snapshots__/SchematicTraceSingleLineSolver2_repro47-short-opposing-pins.snap.svg +20 -2
  158. package/tests/solvers/TraceCleanupSolver/__snapshots__/TraceCleanupSolver.snap.svg +44 -5
  159. package/tests/solvers/TraceLabelOverlapAvoidanceSolver/__snapshots__/TraceLabelOverlapAvoidanceSolver.snap.svg +79 -5
  160. package/tests/solvers/TraceLabelOverlapAvoidanceSolver/sub-solver/__snapshots__/MergedNetLabelObstacles.snap.svg +44 -5
  161. package/tests/solvers/TraceLabelOverlapAvoidanceSolver/sub-solver/__snapshots__/OverlapAvoidanceStepSolver.snap.svg +23 -5
  162. package/tests/solvers/TraceLabelOverlapAvoidanceSolver/sub-solver/__snapshots__/SingleOverlapSolver.snap.svg +30 -5
@@ -0,0 +1,1058 @@
1
+ import type { Point } from "@tscircuit/math-utils"
2
+ import type {
3
+ AnyCircuitElement,
4
+ SchematicBox,
5
+ SchematicComponent,
6
+ SchematicNetLabel,
7
+ SchematicPort,
8
+ SchematicText,
9
+ SchematicTrace,
10
+ SourceNet,
11
+ SourcePort,
12
+ SourceSimpleChip,
13
+ SourceTrace,
14
+ } from "circuit-json"
15
+ import type { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
16
+ import type { InlineNetLabelPlacement } from "lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver"
17
+ import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
18
+ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
19
+ import { getPinDirection } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/getPinDirection"
20
+ import type { InputChip, InputPin, InputProblem } from "lib/types/InputProblem"
21
+ import type { FacingDirection } from "lib/utils/dir"
22
+ import { type SchSymbol, symbols } from "schematic-symbols"
23
+
24
+ type SnapshotTrace = Partial<SolvedTracePath> & {
25
+ tracePath: Point[]
26
+ mspPairId?: string
27
+ netId?: string
28
+ }
29
+
30
+ type SolverOutput = {
31
+ traces?: SnapshotTrace[]
32
+ allTraces?: SnapshotTrace[]
33
+ allTracesMerged?: SnapshotTrace[]
34
+ netLabelPlacements?: NetLabelPlacement[]
35
+ inlineNetLabelPlacements?: InlineNetLabelPlacement[]
36
+ }
37
+
38
+ type SolverLike = BaseSolver & {
39
+ inputProblem?: InputProblem
40
+ problem?: InputProblem
41
+ input?: {
42
+ inputProblem?: InputProblem
43
+ problem?: InputProblem
44
+ traces?: SnapshotTrace[]
45
+ allTraces?: SnapshotTrace[]
46
+ allLabelPlacements?: NetLabelPlacement[]
47
+ netLabelPlacements?: NetLabelPlacement[]
48
+ }
49
+ params?: { inputProblem?: InputProblem }
50
+ traces?: SnapshotTrace[]
51
+ allTraces?: SnapshotTrace[]
52
+ outputTraces?: SnapshotTrace[]
53
+ solvedTracePaths?: SnapshotTrace[]
54
+ solvedTracePath?: Point[] | null
55
+ initialTrace?: SnapshotTrace
56
+ connectionPair?: Partial<SnapshotTrace>
57
+ pins?: Array<InputPin & { chipId?: string }>
58
+ netLabelPlacements?: NetLabelPlacement[]
59
+ outputNetLabelPlacements?: NetLabelPlacement[]
60
+ inputNetLabelPlacements?: NetLabelPlacement[]
61
+ initialNetLabelPlacements?: NetLabelPlacement[]
62
+ inlineNetLabelPlacements?: InlineNetLabelPlacement[]
63
+ label?: NetLabelPlacement
64
+ getOutput?: () => SolverOutput
65
+ inlineNetLabelSolver?: { getOutput: () => SolverOutput }
66
+ }
67
+
68
+ const isInputProblem = (value: unknown): value is InputProblem => {
69
+ if (!value || typeof value !== "object") return false
70
+ const candidate = value as Partial<InputProblem>
71
+ return (
72
+ Array.isArray(candidate.chips) &&
73
+ Array.isArray(candidate.directConnections) &&
74
+ Array.isArray(candidate.netConnections)
75
+ )
76
+ }
77
+
78
+ export const getInputProblemFromSolver = (
79
+ solver: BaseSolver,
80
+ ): InputProblem | undefined => {
81
+ const candidate = solver as SolverLike
82
+ const possibleInputs = [
83
+ candidate.inputProblem,
84
+ candidate.problem,
85
+ candidate.input?.inputProblem,
86
+ candidate.input?.problem,
87
+ candidate.params?.inputProblem,
88
+ ]
89
+
90
+ return possibleInputs.find(isInputProblem)
91
+ }
92
+
93
+ const safelyGetOutput = (solver: SolverLike): SolverOutput | undefined => {
94
+ try {
95
+ if (solver.inlineNetLabelSolver?.getOutput) {
96
+ return solver.inlineNetLabelSolver.getOutput()
97
+ }
98
+ return solver.getOutput?.()
99
+ } catch {
100
+ // A partially solved solver can expose getOutput before it is ready. The
101
+ // property fallbacks below still produce a useful snapshot in that case.
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ const isTraceArray = (value: unknown): value is SnapshotTrace[] =>
107
+ Array.isArray(value) &&
108
+ value.every(
109
+ (trace) =>
110
+ trace &&
111
+ typeof trace === "object" &&
112
+ Array.isArray((trace as SnapshotTrace).tracePath),
113
+ )
114
+
115
+ const firstTraceArray = (candidates: unknown[]): SnapshotTrace[] | undefined =>
116
+ candidates.find(isTraceArray)
117
+
118
+ const firstArray = <T>(candidates: unknown[]): T[] | undefined =>
119
+ candidates.find(Array.isArray) as T[] | undefined
120
+
121
+ const getSnapshotData = (solver: BaseSolver) => {
122
+ const candidate = solver as SolverLike
123
+ const output = safelyGetOutput(candidate)
124
+
125
+ let traces = firstTraceArray([
126
+ output?.traces,
127
+ output?.allTraces,
128
+ output?.allTracesMerged,
129
+ candidate.outputTraces,
130
+ candidate.traces,
131
+ candidate.solvedTracePaths,
132
+ candidate.allTraces,
133
+ candidate.input?.traces,
134
+ candidate.input?.allTraces,
135
+ ])
136
+
137
+ if (!traces && candidate.solvedTracePath) {
138
+ traces = [
139
+ {
140
+ ...(candidate.initialTrace ?? candidate.connectionPair),
141
+ tracePath: candidate.solvedTracePath,
142
+ pinIds:
143
+ candidate.connectionPair?.pinIds ??
144
+ candidate.pins?.map((p) => p.pinId),
145
+ },
146
+ ]
147
+ } else if (!traces && candidate.initialTrace) {
148
+ traces = [candidate.initialTrace]
149
+ }
150
+
151
+ const netLabelPlacements =
152
+ firstArray<NetLabelPlacement>([
153
+ output?.netLabelPlacements,
154
+ candidate.outputNetLabelPlacements,
155
+ candidate.netLabelPlacements,
156
+ candidate.inputNetLabelPlacements,
157
+ candidate.initialNetLabelPlacements,
158
+ candidate.input?.allLabelPlacements,
159
+ candidate.input?.netLabelPlacements,
160
+ ]) ?? (candidate.label ? [candidate.label] : [])
161
+
162
+ const inlineNetLabelPlacements =
163
+ firstArray<InlineNetLabelPlacement>([
164
+ output?.inlineNetLabelPlacements,
165
+ candidate.inlineNetLabelPlacements,
166
+ ]) ?? []
167
+
168
+ const tracesWithInlineStubs = [...(traces ?? [])]
169
+ for (const [inlineIndex, placement] of inlineNetLabelPlacements.entries()) {
170
+ if (!placement.stubTracePath) continue
171
+ tracesWithInlineStubs.push({
172
+ tracePath: placement.stubTracePath,
173
+ mspPairId: `inline-stub-${inlineIndex}`,
174
+ globalConnNetId: placement.globalConnNetId,
175
+ userNetId: placement.netId,
176
+ pinIds: placement.pinIds,
177
+ })
178
+ }
179
+
180
+ return {
181
+ traces: tracesWithInlineStubs,
182
+ netLabelPlacements,
183
+ inlineNetLabelPlacements,
184
+ }
185
+ }
186
+
187
+ const getRefdes = (chip: InputChip) => {
188
+ const pinPrefixes = new Set(
189
+ chip.pins
190
+ .map((pin) => pin.pinId.split(".")[0])
191
+ .filter((prefix): prefix is string => Boolean(prefix)),
192
+ )
193
+ if (pinPrefixes.size === 1) return [...pinPrefixes][0]!
194
+ return chip.chipId
195
+ }
196
+
197
+ const getPinLabel = (pinId: string) =>
198
+ pinId.includes(".") ? pinId.slice(pinId.indexOf(".") + 1) : pinId
199
+
200
+ const getPinNumber = (pinId: string) => {
201
+ const label = getPinLabel(pinId)
202
+ return /^\d+$/.test(label) ? Number(label) : undefined
203
+ }
204
+
205
+ const getFacingDirection = (pin: InputPin, chip: InputChip): FacingDirection =>
206
+ pin._facingDirection ?? getPinDirection(pin, chip)
207
+
208
+ const facingDirectionToSide = (
209
+ facingDirection: FacingDirection,
210
+ ): NonNullable<SchematicPort["side_of_component"]> => {
211
+ switch (facingDirection) {
212
+ case "x+":
213
+ return "right"
214
+ case "x-":
215
+ return "left"
216
+ case "y+":
217
+ return "top"
218
+ case "y-":
219
+ return "bottom"
220
+ }
221
+ }
222
+
223
+ const facingDirectionToCircuitJson = (
224
+ facingDirection: FacingDirection,
225
+ ): NonNullable<SchematicPort["facing_direction"]> => {
226
+ switch (facingDirection) {
227
+ case "x+":
228
+ return "right"
229
+ case "x-":
230
+ return "left"
231
+ case "y+":
232
+ return "up"
233
+ case "y-":
234
+ return "down"
235
+ }
236
+ }
237
+
238
+ const getDefaultSymbolBaseName = (refdes: string) => {
239
+ const prefix = refdes.match(/^[A-Za-z]+/)?.[0]?.toUpperCase()
240
+ if (!prefix) return undefined
241
+
242
+ // Match the defaults used by tscircuit/core's normal components. The more
243
+ // specific LED prefix must be checked before the generic inductor prefix.
244
+ if (prefix === "LED") return "led"
245
+ if (prefix.startsWith("R")) return "boxresistor"
246
+ if (["C", "CIN", "COUT", "CCOMP"].includes(prefix)) return "capacitor"
247
+ if (prefix === "L") return "inductor"
248
+ if (prefix.startsWith("D")) return "diode"
249
+ if (prefix === "F") return "fuse"
250
+ if (["B", "BT", "BAT"].includes(prefix)) return "battery"
251
+ if (prefix === "X" || prefix === "Y") return "crystal"
252
+ if (prefix === "SW") return "spst_switch"
253
+ if (prefix === "I") return "current_source"
254
+ if (prefix === "V" || prefix === "VS") return "ac_voltmeter"
255
+
256
+ return undefined
257
+ }
258
+
259
+ const getPinsInNumberOrder = (chip: InputChip) =>
260
+ [...chip.pins].sort((pinA, pinB) => {
261
+ const pinNumberA = getPinNumber(pinA.pinId)
262
+ const pinNumberB = getPinNumber(pinB.pinId)
263
+ if (pinNumberA === undefined || pinNumberB === undefined) return 0
264
+ return pinNumberA - pinNumberB
265
+ })
266
+
267
+ const getDefaultSymbolName = (chip: InputChip, refdes: string) => {
268
+ if (chip.pins.length !== 2) return undefined
269
+ if (chip.symbolName && chip.symbolName in symbols) return chip.symbolName
270
+ const baseSymbolName = getDefaultSymbolBaseName(refdes)
271
+ if (!baseSymbolName) return undefined
272
+
273
+ const [firstPin, secondPin] = getPinsInNumberOrder(chip)
274
+ const deltaX = secondPin!.x - firstPin!.x
275
+ const deltaY = secondPin!.y - firstPin!.y
276
+ const isHorizontal = Math.abs(deltaX) >= Math.abs(deltaY)
277
+
278
+ const suffix =
279
+ baseSymbolName === "fuse"
280
+ ? isHorizontal
281
+ ? "horz"
282
+ : "vert"
283
+ : isHorizontal
284
+ ? deltaX >= 0
285
+ ? "right"
286
+ : "left"
287
+ : deltaY >= 0
288
+ ? "up"
289
+ : "down"
290
+ const symbolName = `${baseSymbolName}_${suffix}`
291
+
292
+ return symbolName in symbols ? symbolName : undefined
293
+ }
294
+
295
+ const getAngularDifference = (angle1: number, angle2: number) => {
296
+ const normalizedAngle1 = angle1 < 0 ? angle1 + 2 * Math.PI : angle1
297
+ const normalizedAngle2 = angle2 < 0 ? angle2 + 2 * Math.PI : angle2
298
+ const difference = Math.abs(normalizedAngle1 - normalizedAngle2)
299
+ return difference > Math.PI ? 2 * Math.PI - difference : difference
300
+ }
301
+
302
+ /**
303
+ * circuit-to-svg@0.0.407 derives a symbol transform from two schematic ports.
304
+ * Its scale is correct, but its translation is calculated in unscaled symbol
305
+ * coordinates. Offsetting the component and both ports here makes the rendered
306
+ * symbol terminals land on the original solver pin coordinates.
307
+ */
308
+ const getCircuitToSvgSymbolOffset = ({
309
+ chip,
310
+ componentCenter,
311
+ symbol,
312
+ scale,
313
+ }: {
314
+ chip: InputChip
315
+ componentCenter: Point
316
+ symbol: SchSymbol
317
+ scale: number
318
+ }): Point => {
319
+ const pinsByAngle = chip.pins
320
+ .map((pin) => ({
321
+ pin,
322
+ angle: Math.atan2(pin.y - componentCenter.y, pin.x - componentCenter.x),
323
+ }))
324
+ .sort((a, b) => a.angle - b.angle)
325
+ const symbolPortsByAngle = symbol.ports
326
+ .map((port) => ({
327
+ port,
328
+ angle: Math.atan2(port.y - symbol.center.y, port.x - symbol.center.x),
329
+ }))
330
+ .sort((a, b) => a.angle - b.angle)
331
+
332
+ const matches: Array<{
333
+ pin: InputPin
334
+ symbolPort: SchSymbol["ports"][number]
335
+ }> = []
336
+ const usedSymbolPorts = new Set<SchSymbol["ports"][number]>()
337
+ for (const pinWithAngle of pinsByAngle) {
338
+ const match = symbolPortsByAngle
339
+ .filter(({ port }) => !usedSymbolPorts.has(port))
340
+ .map(({ port, angle }) => ({
341
+ port,
342
+ difference: getAngularDifference(pinWithAngle.angle, angle),
343
+ }))
344
+ .sort((a, b) => a.difference - b.difference)[0]
345
+ if (!match || match.difference >= Math.PI / 4) continue
346
+ matches.push({ pin: pinWithAngle.pin, symbolPort: match.port })
347
+ usedSymbolPorts.add(match.port)
348
+ }
349
+
350
+ if (matches.length !== 2) return { x: 0, y: 0 }
351
+
352
+ // Match circuit-to-svg's point-pair order: the second match is the
353
+ // translation anchor and the first match determines the scale.
354
+ const anchorMatch = matches[1]!
355
+ const otherMatch = matches[0]!
356
+ const symbolPortDistance = Math.hypot(
357
+ otherMatch.symbolPort.x - anchorMatch.symbolPort.x,
358
+ otherMatch.symbolPort.y - anchorMatch.symbolPort.y,
359
+ )
360
+ if (symbolPortDistance === 0) return { x: 0, y: 0 }
361
+
362
+ return {
363
+ x: (1 - scale) * anchorMatch.symbolPort.x,
364
+ y: (1 - scale) * anchorMatch.symbolPort.y,
365
+ }
366
+ }
367
+
368
+ type SnapshotSymbolGeometry = {
369
+ symbolName: string
370
+ center: Point
371
+ size: { width: number; height: number }
372
+ circuitToSvgOffset: Point
373
+ }
374
+
375
+ const GENERIC_BOX_STEM_LENGTH = 0.4
376
+ const CHIP_EDGE_TOLERANCE = 1e-6
377
+
378
+ /**
379
+ * Legacy solver inputs describe generic box obstacles from stem tip to stem
380
+ * tip. Circuit JSON's schematic_component size describes only the box body,
381
+ * while each schematic_port's distance_from_component_edge draws its stem.
382
+ * Newer inputs already place pin tips outside the box, so only contract an
383
+ * axis when a pin tip lies on that obstacle's edge.
384
+ */
385
+ const getGenericBoxSize = (chip: InputChip) => {
386
+ const halfWidth = chip.width / 2
387
+ const halfHeight = chip.height / 2
388
+ const hasHorizontalPinOnEdge = chip.pins.some((pin) => {
389
+ const direction = getFacingDirection(pin, chip)
390
+ return (
391
+ (direction === "x+" || direction === "x-") &&
392
+ Math.abs(Math.abs(pin.x - chip.center.x) - halfWidth) <=
393
+ CHIP_EDGE_TOLERANCE
394
+ )
395
+ })
396
+ const hasVerticalPinOnEdge = chip.pins.some((pin) => {
397
+ const direction = getFacingDirection(pin, chip)
398
+ return (
399
+ (direction === "y+" || direction === "y-") &&
400
+ Math.abs(Math.abs(pin.y - chip.center.y) - halfHeight) <=
401
+ CHIP_EDGE_TOLERANCE
402
+ )
403
+ })
404
+
405
+ return {
406
+ width: hasHorizontalPinOnEdge
407
+ ? Math.max(0.1, chip.width - 2 * GENERIC_BOX_STEM_LENGTH)
408
+ : chip.width,
409
+ height: hasVerticalPinOnEdge
410
+ ? Math.max(0.1, chip.height - 2 * GENERIC_BOX_STEM_LENGTH)
411
+ : chip.height,
412
+ }
413
+ }
414
+
415
+ const getGenericBoxPortStemLength = ({
416
+ pin,
417
+ chip,
418
+ componentSize,
419
+ sideOfComponent,
420
+ }: {
421
+ pin: InputPin
422
+ chip: InputChip
423
+ componentSize: { width: number; height: number }
424
+ sideOfComponent: "left" | "right" | "top" | "bottom"
425
+ }) => {
426
+ const distanceFromBody =
427
+ sideOfComponent === "left" || sideOfComponent === "right"
428
+ ? Math.abs(pin.x - chip.center.x) - componentSize.width / 2
429
+ : Math.abs(pin.y - chip.center.y) - componentSize.height / 2
430
+
431
+ return Math.max(0, Math.min(GENERIC_BOX_STEM_LENGTH, distanceFromBody))
432
+ }
433
+
434
+ /**
435
+ * Core passes the solver a text-inclusive chip obstacle. Its center and size
436
+ * can therefore include {REF}, {VAL}, or explicit MPN text and must not be used
437
+ * as the drawable symbol bounds. Reconstruct the original component geometry
438
+ * from the two schematic pin positions and the selected symbol instead.
439
+ */
440
+ const getSnapshotSymbolGeometry = (
441
+ chip: InputChip,
442
+ refdes: string,
443
+ ): SnapshotSymbolGeometry | undefined => {
444
+ const symbolName = getDefaultSymbolName(chip, refdes)
445
+ if (!symbolName) return undefined
446
+ const symbol = symbols[symbolName as keyof typeof symbols] as
447
+ | SchSymbol
448
+ | undefined
449
+ if (!symbol || chip.pins.length !== 2 || symbol.ports.length !== 2) {
450
+ return undefined
451
+ }
452
+
453
+ const unusedSymbolPorts = new Set(symbol.ports)
454
+ const matches = chip.pins.map((pin) => {
455
+ const pinLabel = getPinLabel(pin.pinId)
456
+ const symbolPort = [...unusedSymbolPorts].find((port) =>
457
+ port.labels.includes(pinLabel),
458
+ )
459
+ if (symbolPort) unusedSymbolPorts.delete(symbolPort)
460
+ return symbolPort ? { pin, symbolPort } : undefined
461
+ })
462
+
463
+ const matchedByLabel = matches.every(
464
+ (match): match is NonNullable<typeof match> => Boolean(match),
465
+ )
466
+ const pinPortMatches = matchedByLabel
467
+ ? matches
468
+ : getPinsInNumberOrder(chip).map((pin, index) => ({
469
+ pin,
470
+ symbolPort: symbol.ports[index]!,
471
+ }))
472
+
473
+ const [firstMatch, secondMatch] = pinPortMatches
474
+ const symbolPortDistance = Math.hypot(
475
+ secondMatch!.symbolPort.x - firstMatch!.symbolPort.x,
476
+ secondMatch!.symbolPort.y - firstMatch!.symbolPort.y,
477
+ )
478
+ const schematicPortDistance = Math.hypot(
479
+ secondMatch!.pin.x - firstMatch!.pin.x,
480
+ secondMatch!.pin.y - firstMatch!.pin.y,
481
+ )
482
+ if (symbolPortDistance === 0 || schematicPortDistance === 0) return undefined
483
+
484
+ const scale = schematicPortDistance / symbolPortDistance
485
+ const centers = pinPortMatches.map(({ pin, symbolPort }) => ({
486
+ x: pin.x - (symbolPort.x - symbol.center.x) * scale,
487
+ y: pin.y - (symbolPort.y - symbol.center.y) * scale,
488
+ }))
489
+ const center = {
490
+ x: (centers[0]!.x + centers[1]!.x) / 2,
491
+ y: (centers[0]!.y + centers[1]!.y) / 2,
492
+ }
493
+
494
+ return {
495
+ symbolName,
496
+ center,
497
+ size: {
498
+ width: symbol.size.width * scale,
499
+ height: symbol.size.height * scale,
500
+ },
501
+ circuitToSvgOffset: getCircuitToSvgSymbolOffset({
502
+ chip,
503
+ componentCenter: center,
504
+ symbol,
505
+ scale,
506
+ }),
507
+ }
508
+ }
509
+
510
+ const pointKey = (point: Point) => `${point.x.toFixed(9)},${point.y.toFixed(9)}`
511
+
512
+ const traceKey = (trace: SnapshotTrace) =>
513
+ trace.mspPairId ?? trace.tracePath.map(pointKey).join(";")
514
+
515
+ const dedupeTraces = (traces: SnapshotTrace[]) => {
516
+ const uniqueTraces = new Map<string, SnapshotTrace>()
517
+ for (const trace of traces) {
518
+ if (trace.tracePath.length < 2) continue
519
+ uniqueTraces.set(traceKey(trace), trace)
520
+ }
521
+ return [...uniqueTraces.values()]
522
+ }
523
+
524
+ const getTracePinIds = (trace: SnapshotTrace) =>
525
+ trace.pinIds ?? trace.pins?.map((pin) => pin.pinId) ?? []
526
+
527
+ const nonEmptyNetName = (name: string | undefined) => {
528
+ const trimmedName = name?.trim()
529
+ return trimmedName ? trimmedName : undefined
530
+ }
531
+
532
+ /**
533
+ * circuit-to-svg sizes conventional net labels from their text. Approximate a
534
+ * missing label with X characters so the rendered outline stays close to the
535
+ * width that @tscircuit/core measured and supplied to the solver.
536
+ */
537
+ const getAnchoredNetLabelPlaceholder = (label: NetLabelPlacement) => {
538
+ const labelWidth =
539
+ label.orientation === "x+" || label.orientation === "x-"
540
+ ? label.width
541
+ : label.height
542
+ const fontSize = 0.18
543
+ const fixedWidthInFontSizes = 0.9
544
+ const xWidthInFontSizes = 16 / 27 + 0.06
545
+ const xCount = Math.max(
546
+ 1,
547
+ Math.round(
548
+ (labelWidth / fontSize - fixedWidthInFontSizes) / xWidthInFontSizes,
549
+ ),
550
+ )
551
+ return "X".repeat(xCount)
552
+ }
553
+
554
+ const getInlineNetLabelPlaceholder = (label: InlineNetLabelPlacement) => {
555
+ const fontScale = label.height / 0.18
556
+ const characterWidth = 0.12 * fontScale
557
+ const xCount = Math.max(1, Math.round(label.width / characterWidth - 1))
558
+ return "X".repeat(xCount)
559
+ }
560
+
561
+ const getNetNameResolvers = (
562
+ inputProblem: InputProblem,
563
+ traces: SnapshotTrace[],
564
+ netLabelPlacements: NetLabelPlacement[],
565
+ inlineNetLabelPlacements: InlineNetLabelPlacement[],
566
+ ) => {
567
+ const netNameByPinId = new Map<string, string>()
568
+ const netLabelTextByPinId = new Map<string, string>()
569
+ for (const connection of inputProblem.directConnections) {
570
+ for (const pinId of connection.pinIds) {
571
+ if (connection.netId) netNameByPinId.set(pinId, connection.netId)
572
+ const netLabelText = nonEmptyNetName(connection.netLabelText)
573
+ if (netLabelText) netLabelTextByPinId.set(pinId, netLabelText)
574
+ }
575
+ }
576
+ for (const connection of inputProblem.netConnections) {
577
+ for (const pinId of connection.pinIds) {
578
+ netNameByPinId.set(pinId, connection.netId)
579
+ const netLabelText = nonEmptyNetName(connection.netLabelText)
580
+ if (netLabelText) netLabelTextByPinId.set(pinId, netLabelText)
581
+ }
582
+ }
583
+
584
+ const inferUniqueFromPinIds = (
585
+ pinIds: readonly string[],
586
+ valuesByPinId: Map<string, string>,
587
+ ) => {
588
+ const names = [
589
+ ...new Set(
590
+ pinIds
591
+ .map((pinId) => valuesByPinId.get(pinId))
592
+ .filter((name): name is string => Boolean(name)),
593
+ ),
594
+ ]
595
+ return names.length === 1 ? names[0] : undefined
596
+ }
597
+ const inferNetNameFromPinIds = (pinIds: readonly string[]) =>
598
+ inferUniqueFromPinIds(pinIds, netNameByPinId)
599
+ const inferNetLabelTextFromPinIds = (pinIds: readonly string[]) =>
600
+ inferUniqueFromPinIds(pinIds, netLabelTextByPinId)
601
+
602
+ const userNetNameByGlobalConnNetId = new Map<string, string>()
603
+ const userNetNameByMspPairId = new Map<string, string>()
604
+ const netLabelTextByGlobalConnNetId = new Map<string, string>()
605
+ const netLabelTextByMspPairId = new Map<string, string>()
606
+ for (const trace of traces) {
607
+ const userNetName =
608
+ nonEmptyNetName(trace.userNetId) ??
609
+ inferNetNameFromPinIds(getTracePinIds(trace)) ??
610
+ nonEmptyNetName(trace.netId)
611
+ if (!userNetName) continue
612
+ if (trace.globalConnNetId) {
613
+ userNetNameByGlobalConnNetId.set(trace.globalConnNetId, userNetName)
614
+ }
615
+ for (const mspPairId of [
616
+ trace.mspPairId,
617
+ ...(trace.mspConnectionPairIds ?? []),
618
+ ]) {
619
+ if (mspPairId) userNetNameByMspPairId.set(mspPairId, userNetName)
620
+ }
621
+ }
622
+
623
+ for (const label of [...netLabelPlacements, ...inlineNetLabelPlacements]) {
624
+ const userNetName =
625
+ nonEmptyNetName(label.netId) ?? inferNetNameFromPinIds(label.pinIds)
626
+ if (userNetName) {
627
+ userNetNameByGlobalConnNetId.set(label.globalConnNetId, userNetName)
628
+ }
629
+
630
+ const netLabelText =
631
+ nonEmptyNetName(label.netLabelText) ??
632
+ inferNetLabelTextFromPinIds(label.pinIds)
633
+ if (!netLabelText) continue
634
+ netLabelTextByGlobalConnNetId.set(label.globalConnNetId, netLabelText)
635
+ if ("mspConnectionPairIds" in label) {
636
+ for (const mspPairId of label.mspConnectionPairIds) {
637
+ netLabelTextByMspPairId.set(mspPairId, netLabelText)
638
+ }
639
+ } else if (label.mspPairId) {
640
+ netLabelTextByMspPairId.set(label.mspPairId, netLabelText)
641
+ }
642
+ }
643
+
644
+ const getTraceNetName = (trace: SnapshotTrace) =>
645
+ nonEmptyNetName(trace.userNetId) ??
646
+ inferNetNameFromPinIds(getTracePinIds(trace)) ??
647
+ (trace.globalConnNetId
648
+ ? userNetNameByGlobalConnNetId.get(trace.globalConnNetId)
649
+ : undefined) ??
650
+ nonEmptyNetName(trace.netId) ??
651
+ nonEmptyNetName(trace.globalConnNetId)
652
+
653
+ const getNetLabelNetName = (label: NetLabelPlacement) =>
654
+ nonEmptyNetName(label.netId) ??
655
+ inferNetNameFromPinIds(label.pinIds) ??
656
+ label.mspConnectionPairIds
657
+ .map((mspPairId) => userNetNameByMspPairId.get(mspPairId))
658
+ .find((name): name is string => Boolean(name)) ??
659
+ userNetNameByGlobalConnNetId.get(label.globalConnNetId) ??
660
+ nonEmptyNetName(label.dcConnNetId) ??
661
+ nonEmptyNetName(label.globalConnNetId) ??
662
+ "unnamed net"
663
+
664
+ const getInlineNetLabelNetName = (label: InlineNetLabelPlacement) =>
665
+ nonEmptyNetName(label.netId) ??
666
+ inferNetNameFromPinIds(label.pinIds) ??
667
+ (label.mspPairId
668
+ ? userNetNameByMspPairId.get(label.mspPairId)
669
+ : undefined) ??
670
+ userNetNameByGlobalConnNetId.get(label.globalConnNetId) ??
671
+ nonEmptyNetName(label.globalConnNetId) ??
672
+ "unnamed net"
673
+
674
+ const getNetLabelText = (label: NetLabelPlacement) =>
675
+ nonEmptyNetName(label.netLabelText) ??
676
+ inferNetLabelTextFromPinIds(label.pinIds) ??
677
+ label.mspConnectionPairIds
678
+ .map((mspPairId) => netLabelTextByMspPairId.get(mspPairId))
679
+ .find((text): text is string => Boolean(text)) ??
680
+ netLabelTextByGlobalConnNetId.get(label.globalConnNetId) ??
681
+ getAnchoredNetLabelPlaceholder(label)
682
+
683
+ const getInlineNetLabelText = (label: InlineNetLabelPlacement) =>
684
+ nonEmptyNetName(label.netLabelText) ??
685
+ inferNetLabelTextFromPinIds(label.pinIds) ??
686
+ (label.mspPairId
687
+ ? netLabelTextByMspPairId.get(label.mspPairId)
688
+ : undefined) ??
689
+ netLabelTextByGlobalConnNetId.get(label.globalConnNetId) ??
690
+ getInlineNetLabelPlaceholder(label)
691
+
692
+ return {
693
+ getTraceNetName,
694
+ getNetLabelNetName,
695
+ getNetLabelText,
696
+ getInlineNetLabelNetName,
697
+ getInlineNetLabelText,
698
+ }
699
+ }
700
+
701
+ const getJunctionsByTraceIndex = (
702
+ traces: SnapshotTrace[],
703
+ getTraceNetName: (trace: SnapshotTrace) => string | undefined,
704
+ ) => {
705
+ const traceIndexesByNetPoint = new Map<
706
+ string,
707
+ { point: Point; traceIndexes: Set<number> }
708
+ >()
709
+
710
+ for (const [traceIndex, trace] of traces.entries()) {
711
+ const netKey = trace.globalConnNetId ?? getTraceNetName(trace) ?? "unknown"
712
+ for (const key of new Set(trace.tracePath.map(pointKey))) {
713
+ const netPointKey = `${netKey}:${key}`
714
+ const entry = traceIndexesByNetPoint.get(netPointKey) ?? {
715
+ point: trace.tracePath.find((point) => pointKey(point) === key)!,
716
+ traceIndexes: new Set<number>(),
717
+ }
718
+ entry.traceIndexes.add(traceIndex)
719
+ traceIndexesByNetPoint.set(netPointKey, entry)
720
+ }
721
+ }
722
+
723
+ const junctionsByTraceIndex = new Map<number, Point[]>()
724
+ for (const {
725
+ point: sharedPoint,
726
+ traceIndexes,
727
+ } of traceIndexesByNetPoint.values()) {
728
+ if (traceIndexes.size < 2) continue
729
+ const traceIndex = Math.min(...traceIndexes)
730
+ const junctions = junctionsByTraceIndex.get(traceIndex) ?? []
731
+ if (!junctions.some((point) => pointKey(point) === pointKey(sharedPoint))) {
732
+ junctions.push(sharedPoint)
733
+ }
734
+ junctionsByTraceIndex.set(traceIndex, junctions)
735
+ }
736
+
737
+ return junctionsByTraceIndex
738
+ }
739
+
740
+ const orientationToAnchorSide = (
741
+ orientation: FacingDirection,
742
+ ): SchematicNetLabel["anchor_side"] => {
743
+ switch (orientation) {
744
+ case "x+":
745
+ return "left"
746
+ case "x-":
747
+ return "right"
748
+ case "y+":
749
+ return "bottom"
750
+ case "y-":
751
+ return "top"
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Builds a semantic, renderable Circuit JSON schematic from a solver snapshot.
757
+ * Only solved/output traces are emitted, so input connection lines (the rats
758
+ * nest) are intentionally absent.
759
+ */
760
+ export const convertSolverOutputToCircuitJson = (
761
+ solver: BaseSolver,
762
+ ): AnyCircuitElement[] => {
763
+ const inputProblem = getInputProblemFromSolver(solver)
764
+ if (!inputProblem) {
765
+ throw new Error(
766
+ `Unable to find an InputProblem on ${solver.constructor.name}`,
767
+ )
768
+ }
769
+
770
+ const snapshotData = getSnapshotData(solver)
771
+ const traces = dedupeTraces(snapshotData.traces)
772
+ const {
773
+ getTraceNetName,
774
+ getNetLabelNetName,
775
+ getNetLabelText,
776
+ getInlineNetLabelNetName,
777
+ getInlineNetLabelText,
778
+ } = getNetNameResolvers(
779
+ inputProblem,
780
+ traces,
781
+ snapshotData.netLabelPlacements,
782
+ snapshotData.inlineNetLabelPlacements,
783
+ )
784
+ const circuitJson: AnyCircuitElement[] = []
785
+ const sourcePortIdByPinId = new Map<string, string>()
786
+ const schematicPortIdByPinId = new Map<string, string>()
787
+ const sourcePortIdsByPoint = new Map<string, string[]>()
788
+ const schematicPortIdsByPoint = new Map<string, string[]>()
789
+
790
+ for (const [chipIndex, chip] of inputProblem.chips.entries()) {
791
+ const sourceComponentId = `source_component_${chipIndex}`
792
+ const schematicComponentId = `schematic_component_${chipIndex}`
793
+ const refdes = getRefdes(chip)
794
+ const symbolGeometry = getSnapshotSymbolGeometry(chip, refdes)
795
+ const symbolName = symbolGeometry?.symbolName
796
+ const symbolOffset = symbolGeometry?.circuitToSvgOffset ?? { x: 0, y: 0 }
797
+ const componentCenter = symbolGeometry?.center ?? chip.center
798
+ const componentSize = symbolGeometry?.size ?? getGenericBoxSize(chip)
799
+
800
+ circuitJson.push({
801
+ type: "source_component",
802
+ ftype: "simple_chip",
803
+ source_component_id: sourceComponentId,
804
+ name: refdes,
805
+ } satisfies SourceSimpleChip)
806
+
807
+ circuitJson.push({
808
+ type: "schematic_component",
809
+ schematic_component_id: schematicComponentId,
810
+ source_component_id: sourceComponentId,
811
+ center: {
812
+ x: componentCenter.x + symbolOffset.x,
813
+ y: componentCenter.y + symbolOffset.y,
814
+ },
815
+ size: componentSize,
816
+ is_box_with_pins: true,
817
+ symbol_name: symbolName,
818
+ } satisfies SchematicComponent)
819
+
820
+ if (!symbolName) {
821
+ circuitJson.push({
822
+ type: "schematic_text",
823
+ schematic_text_id: `schematic_component_label_${chipIndex}`,
824
+ schematic_component_id: schematicComponentId,
825
+ text: refdes,
826
+ font_size: Math.max(0.1, Math.min(0.18, chip.height / 3)),
827
+ position: chip.center,
828
+ rotation: 0,
829
+ anchor: "center",
830
+ color: "#0f172a",
831
+ } satisfies SchematicText)
832
+ }
833
+
834
+ for (const [pinIndex, pin] of chip.pins.entries()) {
835
+ const sourcePortId = `source_port_${chipIndex}_${pinIndex}`
836
+ const schematicPortId = `schematic_port_${chipIndex}_${pinIndex}`
837
+ const facingDirection = getFacingDirection(pin, chip)
838
+ const sideOfComponent = facingDirectionToSide(facingDirection)
839
+ const pinLabel = getPinLabel(pin.pinId)
840
+ const pinNumber = getPinNumber(pin.pinId)
841
+ const distanceFromComponentEdge = symbolName
842
+ ? Math.min(
843
+ 0.2,
844
+ (sideOfComponent === "left" || sideOfComponent === "right"
845
+ ? componentSize.width
846
+ : componentSize.height) / 3,
847
+ )
848
+ : getGenericBoxPortStemLength({
849
+ pin,
850
+ chip,
851
+ componentSize,
852
+ sideOfComponent,
853
+ })
854
+
855
+ circuitJson.push({
856
+ type: "source_port",
857
+ source_port_id: sourcePortId,
858
+ source_component_id: sourceComponentId,
859
+ name: pinLabel,
860
+ pin_number: pinNumber,
861
+ } satisfies SourcePort)
862
+
863
+ circuitJson.push({
864
+ type: "schematic_port",
865
+ schematic_port_id: schematicPortId,
866
+ source_port_id: sourcePortId,
867
+ schematic_component_id: schematicComponentId,
868
+ center: {
869
+ x: pin.x + symbolOffset.x,
870
+ y: pin.y + symbolOffset.y,
871
+ },
872
+ facing_direction: facingDirectionToCircuitJson(facingDirection),
873
+ side_of_component: sideOfComponent,
874
+ distance_from_component_edge: distanceFromComponentEdge,
875
+ display_pin_label: pinNumber === undefined ? pinLabel : undefined,
876
+ pin_number: pinNumber,
877
+ } satisfies SchematicPort)
878
+
879
+ sourcePortIdByPinId.set(pin.pinId, sourcePortId)
880
+ schematicPortIdByPinId.set(pin.pinId, schematicPortId)
881
+ const key = pointKey(pin)
882
+ sourcePortIdsByPoint.set(key, [
883
+ ...(sourcePortIdsByPoint.get(key) ?? []),
884
+ sourcePortId,
885
+ ])
886
+ schematicPortIdsByPoint.set(key, [
887
+ ...(schematicPortIdsByPoint.get(key) ?? []),
888
+ schematicPortId,
889
+ ])
890
+ }
891
+ }
892
+
893
+ for (const [textBoxIndex, textBox] of (
894
+ inputProblem.textBoxes ?? []
895
+ ).entries()) {
896
+ circuitJson.push({
897
+ type: "schematic_box",
898
+ schematic_component_id: textBox.chipId,
899
+ x: textBox.center.x - textBox.width / 2,
900
+ y: textBox.center.y - textBox.height / 2,
901
+ width: textBox.width,
902
+ height: textBox.height,
903
+ is_dashed: true,
904
+ } satisfies SchematicBox)
905
+
906
+ if (textBox.text) {
907
+ circuitJson.push({
908
+ type: "schematic_text",
909
+ schematic_text_id: `schematic_text_box_${textBoxIndex}`,
910
+ text: textBox.text,
911
+ font_size: Math.max(0.08, Math.min(0.18, textBox.height)),
912
+ position: textBox.center,
913
+ rotation: 0,
914
+ anchor: "center",
915
+ color: "#7e22ce",
916
+ } satisfies SchematicText)
917
+ }
918
+ }
919
+
920
+ const netNames = new Set<string>()
921
+ for (const connection of [
922
+ ...inputProblem.directConnections,
923
+ ...inputProblem.netConnections,
924
+ ]) {
925
+ if (connection.netId) netNames.add(connection.netId)
926
+ }
927
+ for (const trace of traces) {
928
+ const netName = getTraceNetName(trace)
929
+ if (netName) netNames.add(netName)
930
+ }
931
+ for (const label of snapshotData.netLabelPlacements) {
932
+ netNames.add(getNetLabelNetName(label))
933
+ }
934
+ for (const label of snapshotData.inlineNetLabelPlacements) {
935
+ netNames.add(getInlineNetLabelNetName(label))
936
+ }
937
+
938
+ const sourceNetIdByName = new Map<string, string>()
939
+ for (const [netIndex, netName] of [...netNames].sort().entries()) {
940
+ const sourceNetId = `source_net_${netIndex}`
941
+ sourceNetIdByName.set(netName, sourceNetId)
942
+ circuitJson.push({
943
+ type: "source_net",
944
+ source_net_id: sourceNetId,
945
+ name: netName,
946
+ member_source_group_ids: [],
947
+ is_ground: /(^|[._-])gnd($|[._-])/i.test(netName),
948
+ is_power: /(^|[._-])(vcc|vdd|vss|gnd)($|[._-])/i.test(netName),
949
+ } satisfies SourceNet)
950
+ }
951
+
952
+ const junctionsByTraceIndex = getJunctionsByTraceIndex(
953
+ traces,
954
+ getTraceNetName,
955
+ )
956
+ for (const [traceIndex, trace] of traces.entries()) {
957
+ const sourceTraceId = `source_trace_${traceIndex}`
958
+ const schematicTraceId = `schematic_trace_${traceIndex}`
959
+ const tracePinIds = getTracePinIds(trace)
960
+ const startKey = pointKey(trace.tracePath[0]!)
961
+ const endKey = pointKey(trace.tracePath.at(-1)!)
962
+ const sourcePortIds = [
963
+ ...tracePinIds
964
+ .map((pinId) => sourcePortIdByPinId.get(pinId))
965
+ .filter((id): id is string => Boolean(id)),
966
+ ]
967
+ if (sourcePortIds.length === 0) {
968
+ sourcePortIds.push(
969
+ ...(sourcePortIdsByPoint.get(startKey)?.slice(0, 1) ?? []),
970
+ ...(sourcePortIdsByPoint.get(endKey)?.slice(0, 1) ?? []),
971
+ )
972
+ }
973
+ const netName = getTraceNetName(trace)
974
+
975
+ circuitJson.push({
976
+ type: "source_trace",
977
+ source_trace_id: sourceTraceId,
978
+ connected_source_port_ids: [...new Set(sourcePortIds)],
979
+ connected_source_net_ids: netName
980
+ ? [sourceNetIdByName.get(netName)!]
981
+ : [],
982
+ name: netName,
983
+ } satisfies SourceTrace)
984
+
985
+ circuitJson.push({
986
+ type: "schematic_trace",
987
+ schematic_trace_id: schematicTraceId,
988
+ source_trace_id: sourceTraceId,
989
+ junctions: junctionsByTraceIndex.get(traceIndex) ?? [],
990
+ edges: trace.tracePath.slice(0, -1).map((point, pointIndex) => {
991
+ const nextPoint = trace.tracePath[pointIndex + 1]!
992
+ const fromSchematicPortId =
993
+ pointIndex === 0
994
+ ? (tracePinIds
995
+ .map((pinId) => schematicPortIdByPinId.get(pinId))
996
+ .find(
997
+ (portId) =>
998
+ portId &&
999
+ schematicPortIdsByPoint
1000
+ .get(pointKey(point))
1001
+ ?.includes(portId),
1002
+ ) ?? schematicPortIdsByPoint.get(pointKey(point))?.[0])
1003
+ : undefined
1004
+ const toSchematicPortId =
1005
+ pointIndex === trace.tracePath.length - 2
1006
+ ? (tracePinIds
1007
+ .map((pinId) => schematicPortIdByPinId.get(pinId))
1008
+ .find(
1009
+ (portId) =>
1010
+ portId &&
1011
+ schematicPortIdsByPoint
1012
+ .get(pointKey(nextPoint))
1013
+ ?.includes(portId),
1014
+ ) ?? schematicPortIdsByPoint.get(pointKey(nextPoint))?.[0])
1015
+ : undefined
1016
+
1017
+ return {
1018
+ from: point,
1019
+ to: nextPoint,
1020
+ from_schematic_port_id: fromSchematicPortId,
1021
+ to_schematic_port_id: toSchematicPortId,
1022
+ }
1023
+ }),
1024
+ } satisfies SchematicTrace)
1025
+ }
1026
+
1027
+ for (const [labelIndex, label] of snapshotData.netLabelPlacements.entries()) {
1028
+ const netName = getNetLabelNetName(label)
1029
+ circuitJson.push({
1030
+ type: "schematic_net_label",
1031
+ schematic_net_label_id: `schematic_net_label_${labelIndex}`,
1032
+ source_net_id: sourceNetIdByName.get(netName)!,
1033
+ center: label.center,
1034
+ anchor_position: label.anchorPoint,
1035
+ anchor_side: orientationToAnchorSide(label.orientation),
1036
+ text: getNetLabelText(label),
1037
+ } satisfies SchematicNetLabel)
1038
+ }
1039
+
1040
+ for (const [
1041
+ labelIndex,
1042
+ label,
1043
+ ] of snapshotData.inlineNetLabelPlacements.entries()) {
1044
+ circuitJson.push({
1045
+ type: "schematic_text",
1046
+ schematic_text_id: `schematic_inline_net_label_${labelIndex}`,
1047
+ text: getInlineNetLabelText(label),
1048
+ font_size: label.height,
1049
+ position: label.center,
1050
+ // Match core's schematic convention: vertical labels read bottom-to-top.
1051
+ rotation: label.axis === "y" ? -90 : 0,
1052
+ anchor: "center",
1053
+ color: "#047857",
1054
+ } satisfies SchematicText)
1055
+ }
1056
+
1057
+ return circuitJson
1058
+ }