@tscircuit/schematic-trace-solver 0.0.158 → 0.0.160

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 (27) hide show
  1. package/dist/index.d.ts +41 -9
  2. package/dist/index.js +762 -110
  3. package/lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver.ts +536 -94
  4. package/lib/solvers/InlineNetLabelSolver/pushAnchoredNetLabelsAwayFromInlineLabels.ts +9 -7
  5. package/lib/solvers/InlineNetLabelSolver/pushInlineTerminalLabelsAwayFromAnchoredLabels.ts +295 -0
  6. package/lib/solvers/LongDistancePairSolver/LongDistancePairSolver.ts +15 -8
  7. package/lib/solvers/MspConnectionPairSolver/MspConnectionPairSolver.ts +5 -0
  8. package/lib/solvers/MspConnectionPairSolver/getGroundConnectionPolicy.ts +83 -0
  9. package/lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver.ts +7 -35
  10. package/lib/solvers/SameNetJunctionAlignmentSolver/SameNetJunctionAlignmentSolver.ts +6 -1
  11. package/lib/solvers/SameNetJunctionAlignmentSolver/placeGroundRailLabelsAtOuterEnd.ts +122 -0
  12. package/lib/solvers/SchematicTraceLinesSolver/getTraceConnectedPinComponents.ts +60 -0
  13. package/lib/types/InputProblem.ts +5 -4
  14. package/package.json +1 -1
  15. package/tests/bug-reports/bug-report-20260826T072956Z/__snapshots__/bug-report-20260826T072956Z.snap.svg +600 -783
  16. package/tests/bug-reports/bug-report-20260826T072956Z/bug-report-20260826T072956Z.test.ts +66 -1
  17. package/tests/fixtures/parallel-ground-rail.ts +53 -0
  18. package/tests/repros/__snapshots__/repro-mspm0l1306-capacitor-symmetry.snap.svg +34 -16
  19. package/tests/repros/repro-mspm0l1306-capacitor-symmetry.test.ts +40 -7
  20. package/tests/repros/repro-usb-power-vbus-label-detour.test.ts +14 -0
  21. package/tests/solvers/InlineNetLabelSolver/multi-pin-connected-components.test.ts +252 -0
  22. package/tests/solvers/InlineNetLabelSolver/opposite-side-placement.test.ts +71 -0
  23. package/tests/solvers/InlineNetLabelSolver/push-anchored-net-labels-away.test.ts +8 -2
  24. package/tests/solvers/InlineNetLabelSolver/push-inline-terminal-labels-away.test.ts +91 -0
  25. package/tests/solvers/InlineNetLabelSolver/two-pin-terminal-stubs-atomic.test.ts +7 -1
  26. package/tests/solvers/MspConnectionPairSolver/local-ground-branches.test.ts +105 -0
  27. package/tests/solvers/SameNetJunctionAlignmentSolver/ground-rail-label.test.ts +188 -0
@@ -485,13 +485,15 @@ export const pushAnchoredNetLabelsAwayFromInlineLabels = ({
485
485
  inlineBounds.some((bounds) =>
486
486
  pathIntersectsBounds(connector.tracePath, bounds),
487
487
  ) ||
488
- inputProblem.chips.some((chip) =>
489
- pathIntersectsBounds(connector.tracePath, {
490
- minX: chip.center.x - chip.width / 2,
491
- maxX: chip.center.x + chip.width / 2,
492
- minY: chip.center.y - chip.height / 2,
493
- maxY: chip.center.y + chip.height / 2,
494
- }),
488
+ inputProblem.chips.some(
489
+ (chip) =>
490
+ !ownerChipIds.has(chip.chipId) &&
491
+ pathIntersectsBounds(connector.tracePath, {
492
+ minX: chip.center.x - chip.width / 2,
493
+ maxX: chip.center.x + chip.width / 2,
494
+ minY: chip.center.y - chip.height / 2,
495
+ maxY: chip.center.y + chip.height / 2,
496
+ }),
495
497
  ) ||
496
498
  (inputProblem.textBoxes ?? []).some((textBox) =>
497
499
  pathIntersectsBounds(connector.tracePath, getTextBoxBounds(textBox)),
@@ -0,0 +1,295 @@
1
+ import type { Bounds, Point } from "@tscircuit/math-utils"
2
+ import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
3
+ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
4
+ import type { InputProblem } from "lib/types/InputProblem"
5
+ import { boundsOverlap, getTextBoxBounds } from "lib/utils/textBoxBounds"
6
+ import type { InlineNetLabelPlacement } from "./InlineNetLabelSolver"
7
+
8
+ type StubDirection = "x+" | "x-" | "y+" | "y-"
9
+
10
+ const LABEL_CLEARANCE = 0.05
11
+ const MAX_OUTWARD_DISTANCE = 5
12
+
13
+ const getStubDirection = (path: [Point, Point]): StubDirection => {
14
+ const [start, end] = path
15
+ if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
16
+ return end.x >= start.x ? "x+" : "x-"
17
+ }
18
+ return end.y >= start.y ? "y+" : "y-"
19
+ }
20
+
21
+ const getLabelBounds = (placement: {
22
+ center: Point
23
+ width: number
24
+ height: number
25
+ axis?: "x" | "y"
26
+ }): Bounds => {
27
+ const renderedWidth =
28
+ placement.axis === "y" ? placement.height : placement.width
29
+ const renderedHeight =
30
+ placement.axis === "y" ? placement.width : placement.height
31
+ return {
32
+ minX: placement.center.x - renderedWidth / 2,
33
+ maxX: placement.center.x + renderedWidth / 2,
34
+ minY: placement.center.y - renderedHeight / 2,
35
+ maxY: placement.center.y + renderedHeight / 2,
36
+ }
37
+ }
38
+
39
+ const getPathBounds = (path: Point[]): Bounds => ({
40
+ minX: Math.min(...path.map((point) => point.x)),
41
+ maxX: Math.max(...path.map((point) => point.x)),
42
+ minY: Math.min(...path.map((point) => point.y)),
43
+ maxY: Math.max(...path.map((point) => point.y)),
44
+ })
45
+
46
+ const doesPathIntersectBounds = (path: Point[], bounds: Bounds) => {
47
+ for (let index = 0; index < path.length - 1; index++) {
48
+ if (
49
+ boundsOverlap(getPathBounds([path[index]!, path[index + 1]!]), bounds)
50
+ ) {
51
+ return true
52
+ }
53
+ }
54
+ return false
55
+ }
56
+
57
+ const shiftPoint = (
58
+ point: Point,
59
+ direction: StubDirection,
60
+ distance: number,
61
+ ): Point => {
62
+ switch (direction) {
63
+ case "x+":
64
+ return { x: point.x + distance, y: point.y }
65
+ case "x-":
66
+ return { x: point.x - distance, y: point.y }
67
+ case "y+":
68
+ return { x: point.x, y: point.y + distance }
69
+ case "y-":
70
+ return { x: point.x, y: point.y - distance }
71
+ }
72
+ }
73
+
74
+ const shiftTerminalPlacement = (
75
+ placement: InlineNetLabelPlacement,
76
+ direction: StubDirection,
77
+ distance: number,
78
+ ): InlineNetLabelPlacement => {
79
+ const [start, end] = placement.stubTracePath!
80
+ const shiftedEnd = shiftPoint(end, direction, distance)
81
+ return {
82
+ ...placement,
83
+ stubTracePath: [start, shiftedEnd],
84
+ anchorPoint: shiftPoint(placement.anchorPoint, direction, distance),
85
+ center: shiftPoint(placement.center, direction, distance),
86
+ }
87
+ }
88
+
89
+ const getRequiredOutwardDistance = (
90
+ movingBounds: Bounds,
91
+ obstacleBounds: Bounds,
92
+ direction: StubDirection,
93
+ ) => {
94
+ switch (direction) {
95
+ case "x+":
96
+ return obstacleBounds.maxX - movingBounds.minX + LABEL_CLEARANCE
97
+ case "x-":
98
+ return movingBounds.maxX - obstacleBounds.minX + LABEL_CLEARANCE
99
+ case "y+":
100
+ return obstacleBounds.maxY - movingBounds.minY + LABEL_CLEARANCE
101
+ case "y-":
102
+ return movingBounds.maxY - obstacleBounds.minY + LABEL_CLEARANCE
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Moves a row of terminal inline labels farther outward when a retained
108
+ * anchored label occupies their near-pin column. Every terminal on the same
109
+ * component side moves together, preserving the row's alignment. Only the
110
+ * newly added stub segment and shifted text are collision-checked; the
111
+ * original pin-to-label segment was already validated when it was created.
112
+ * All points and bounds are in schematic-world millimetres, with +x right and
113
+ * +y up.
114
+ */
115
+ export const pushInlineTerminalLabelsAwayFromAnchoredLabels = ({
116
+ inputProblem,
117
+ traces,
118
+ anchoredNetLabelPlacements,
119
+ inlineNetLabelPlacements,
120
+ }: {
121
+ inputProblem: InputProblem
122
+ traces: SolvedTracePath[]
123
+ anchoredNetLabelPlacements: NetLabelPlacement[]
124
+ inlineNetLabelPlacements: InlineNetLabelPlacement[]
125
+ }): {
126
+ inlineNetLabelPlacements: InlineNetLabelPlacement[]
127
+ movedGroupCount: number
128
+ } => {
129
+ const chipIdByPinId = new Map<string, string>()
130
+ for (const chip of inputProblem.chips) {
131
+ for (const pin of chip.pins) chipIdByPinId.set(pin.pinId, chip.chipId)
132
+ }
133
+
134
+ const groups = new Map<
135
+ string,
136
+ Array<{
137
+ placementIndex: number
138
+ placement: InlineNetLabelPlacement
139
+ direction: StubDirection
140
+ ownerChipId?: string
141
+ }>
142
+ >()
143
+ for (const [
144
+ placementIndex,
145
+ placement,
146
+ ] of inlineNetLabelPlacements.entries()) {
147
+ if (!placement.stubTracePath || placement.pinIds.length !== 1) continue
148
+ const direction = getStubDirection(placement.stubTracePath)
149
+ const ownerChipId = chipIdByPinId.get(placement.pinIds[0]!)
150
+ const groupKey = `${ownerChipId ?? placement.pinIds[0]}::${direction}`
151
+ const group = groups.get(groupKey) ?? []
152
+ group.push({ placementIndex, placement, direction, ownerChipId })
153
+ groups.set(groupKey, group)
154
+ }
155
+
156
+ const outputPlacements = [...inlineNetLabelPlacements]
157
+ let movedGroupCount = 0
158
+
159
+ for (const group of groups.values()) {
160
+ const groupPlacementIndices = new Set(
161
+ group.map(({ placementIndex }) => placementIndex),
162
+ )
163
+ const fixedInlinePlacements = outputPlacements.filter(
164
+ (_, placementIndex) => !groupPlacementIndices.has(placementIndex),
165
+ )
166
+ const direction = group[0]!.direction
167
+ let distance = 0
168
+
169
+ for (
170
+ let iteration = 0;
171
+ iteration <= anchoredNetLabelPlacements.length;
172
+ iteration++
173
+ ) {
174
+ let requiredAdditionalDistance = 0
175
+ for (const { placement } of group) {
176
+ const shiftedPlacement = shiftTerminalPlacement(
177
+ placement,
178
+ direction,
179
+ distance,
180
+ )
181
+ const shiftedBounds = getLabelBounds(shiftedPlacement)
182
+ for (const anchoredPlacement of anchoredNetLabelPlacements) {
183
+ if (anchoredPlacement.globalConnNetId === placement.globalConnNetId) {
184
+ continue
185
+ }
186
+ const anchoredBounds = getLabelBounds(anchoredPlacement)
187
+ if (!boundsOverlap(shiftedBounds, anchoredBounds)) continue
188
+ requiredAdditionalDistance = Math.max(
189
+ requiredAdditionalDistance,
190
+ getRequiredOutwardDistance(
191
+ shiftedBounds,
192
+ anchoredBounds,
193
+ direction,
194
+ ),
195
+ )
196
+ }
197
+ }
198
+ if (requiredAdditionalDistance <= 0) break
199
+ distance += requiredAdditionalDistance
200
+ }
201
+
202
+ if (distance <= 0 || distance > MAX_OUTWARD_DISTANCE) continue
203
+
204
+ const proposals = group.map(({ placement }) =>
205
+ shiftTerminalPlacement(placement, direction, distance),
206
+ )
207
+ const hasConflict = proposals.some((proposal, proposalIndex) => {
208
+ const originalPlacement = group[proposalIndex]!.placement
209
+ const originalEnd = originalPlacement.stubTracePath![1]
210
+ const shiftedEnd = proposal.stubTracePath![1]
211
+ const addedStubSegment: [Point, Point] = [originalEnd, shiftedEnd]
212
+ const labelBounds = getLabelBounds(proposal)
213
+ const ownerChipId = group[proposalIndex]!.ownerChipId
214
+
215
+ if (
216
+ anchoredNetLabelPlacements.some((anchoredPlacement) => {
217
+ if (anchoredPlacement.globalConnNetId === proposal.globalConnNetId) {
218
+ return false
219
+ }
220
+ const anchoredBounds = getLabelBounds(anchoredPlacement)
221
+ return (
222
+ boundsOverlap(labelBounds, anchoredBounds) ||
223
+ doesPathIntersectBounds(addedStubSegment, anchoredBounds)
224
+ )
225
+ })
226
+ ) {
227
+ return true
228
+ }
229
+
230
+ for (const chip of inputProblem.chips) {
231
+ if (chip.chipId === ownerChipId) continue
232
+ const chipBounds: Bounds = {
233
+ minX: chip.center.x - chip.width / 2,
234
+ maxX: chip.center.x + chip.width / 2,
235
+ minY: chip.center.y - chip.height / 2,
236
+ maxY: chip.center.y + chip.height / 2,
237
+ }
238
+ if (
239
+ boundsOverlap(labelBounds, chipBounds) ||
240
+ doesPathIntersectBounds(addedStubSegment, chipBounds)
241
+ ) {
242
+ return true
243
+ }
244
+ }
245
+
246
+ for (const textBox of inputProblem.textBoxes ?? []) {
247
+ const textBounds = getTextBoxBounds(textBox)
248
+ if (
249
+ boundsOverlap(labelBounds, textBounds) ||
250
+ doesPathIntersectBounds(addedStubSegment, textBounds)
251
+ ) {
252
+ return true
253
+ }
254
+ }
255
+
256
+ for (const trace of traces) {
257
+ if (trace.globalConnNetId === proposal.globalConnNetId) continue
258
+ if (
259
+ doesPathIntersectBounds(trace.tracePath, labelBounds) ||
260
+ doesPathIntersectBounds(
261
+ trace.tracePath,
262
+ getPathBounds(addedStubSegment),
263
+ )
264
+ ) {
265
+ return true
266
+ }
267
+ }
268
+
269
+ for (const fixedPlacement of fixedInlinePlacements) {
270
+ const fixedBounds = getLabelBounds(fixedPlacement)
271
+ if (
272
+ boundsOverlap(labelBounds, fixedBounds) ||
273
+ doesPathIntersectBounds(addedStubSegment, fixedBounds) ||
274
+ (fixedPlacement.stubTracePath &&
275
+ doesPathIntersectBounds(fixedPlacement.stubTracePath, labelBounds))
276
+ ) {
277
+ return true
278
+ }
279
+ }
280
+
281
+ return false
282
+ })
283
+
284
+ if (hasConflict) continue
285
+ for (const [groupIndex, { placementIndex }] of group.entries()) {
286
+ outputPlacements[placementIndex] = proposals[groupIndex]!
287
+ }
288
+ movedGroupCount++
289
+ }
290
+
291
+ return {
292
+ inlineNetLabelPlacements: outputPlacements,
293
+ movedGroupCount,
294
+ }
295
+ }
@@ -1,23 +1,24 @@
1
- import { getConnectivityMapsFromInputProblem } from "lib/solvers/MspConnectionPairSolver/getConnectivityMapFromInputProblem"
2
1
  import type { Point } from "@tscircuit/math-utils"
2
+ import type { ConnectivityMap } from "connectivity-map"
3
+ import { getConnectivityMapsFromInputProblem } from "lib/solvers/MspConnectionPairSolver/getConnectivityMapFromInputProblem"
3
4
  import {
4
5
  DEFAULT_MAX_MSP_PAIR_DISTANCE,
5
6
  type MspConnectionPair,
6
7
  } from "lib/solvers/MspConnectionPairSolver/MspConnectionPairSolver"
7
8
  import type {
8
- InputProblem,
9
+ InputChip,
9
10
  InputPin,
11
+ InputProblem,
10
12
  PinId,
11
- InputChip,
12
13
  } from "lib/types/InputProblem"
13
- import { BaseSolver } from "../BaseSolver/BaseSolver"
14
- import { SchematicTraceSingleLineSolver2 } from "../SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2"
15
- import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualizeInputProblem"
16
- import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver"
17
- import type { ConnectivityMap } from "connectivity-map"
18
14
  import { doesTraceOverlapWithExistingTraces } from "lib/utils/does-trace-overlap-with-existing-traces"
19
15
  import { arePinsInDifferentSchematicSections } from "../../utils/arePinsInDifferentSchematicSections"
16
+ import { BaseSolver } from "../BaseSolver/BaseSolver"
17
+ import { getGroundConnectionPolicy } from "../MspConnectionPairSolver/getGroundConnectionPolicy"
20
18
  import { isLabeledPeripheralConnection } from "../MspConnectionPairSolver/isLabeledPeripheralConnection"
19
+ import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver"
20
+ import { SchematicTraceSingleLineSolver2 } from "../SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2"
21
+ import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualizeInputProblem"
21
22
 
22
23
  const NEAREST_NEIGHBOR_COUNT = 3
23
24
 
@@ -163,6 +164,7 @@ export class LongDistancePairSolver extends BaseSolver {
163
164
 
164
165
  const { inputProblem, primaryMspConnectionPairs, alreadySolvedTraces } =
165
166
  this.params
167
+ const canRouteGroundPair = getGroundConnectionPolicy(inputProblem)
166
168
 
167
169
  this.inputProblem = inputProblem
168
170
  this.allSolvedTraces = [...alreadySolvedTraces]
@@ -189,6 +191,10 @@ export class LongDistancePairSolver extends BaseSolver {
189
191
  // new nearest-neighbor candidates.
190
192
  this.queuedFailedConnectionPairs = this.params.failedConnectionPairs.filter(
191
193
  (connectionPair) =>
194
+ canRouteGroundPair(
195
+ connectionPair.pins[0].pinId,
196
+ connectionPair.pins[1].pinId,
197
+ ) &&
192
198
  isLabeledPeripheralConnection({
193
199
  inputProblem: this.inputProblem,
194
200
  chipMap: this.chipMap,
@@ -219,6 +225,7 @@ export class LongDistancePairSolver extends BaseSolver {
219
225
  .flatMap((otherPinId) => {
220
226
  const targetPin = pinMap.get(otherPinId)
221
227
  if (!targetPin) return [] // Gracefully handle missing pins
228
+ if (!canRouteGroundPair(sourcePin.pinId, targetPin.pinId)) return []
222
229
  const isNamedTwoPinConnection = inputProblem.netConnections.some(
223
230
  (connection) =>
224
231
  connection.pinIds.length === 2 &&
@@ -13,6 +13,7 @@ import { arePinsInDifferentSchematicSections } from "../../utils/arePinsInDiffer
13
13
  import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualizeInputProblem"
14
14
  import { doesPairCrossRestrictedCenterLines } from "./doesPairCrossRestrictedCenterLines"
15
15
  import { getConnectivityMapsFromInputProblem } from "./getConnectivityMapFromInputProblem"
16
+ import { getGroundConnectionPolicy } from "./getGroundConnectionPolicy"
16
17
  import { getOrthogonalMinimumSpanningTree } from "./getMspConnectionPairsFromPins"
17
18
  import { getLabeledConnectionRouteReason } from "./isLabeledPeripheralConnection"
18
19
 
@@ -45,11 +46,13 @@ export class MspConnectionPairSolver extends BaseSolver {
45
46
  pinMap: Record<string, InputPin & { chipId: string }>
46
47
  userNetIdByPinId: Record<string, string | undefined>
47
48
  directConnectionPinPairKeys: Set<string>
49
+ private canRouteGroundPair: (firstPinId: PinId, secondPinId: PinId) => boolean
48
50
 
49
51
  constructor({ inputProblem }: { inputProblem: InputProblem }) {
50
52
  super()
51
53
 
52
54
  this.inputProblem = inputProblem
55
+ this.canRouteGroundPair = getGroundConnectionPolicy(inputProblem)
53
56
  this.maxMspPairDistance =
54
57
  inputProblem.maxMspPairDistance ?? DEFAULT_MAX_MSP_PAIR_DISTANCE
55
58
 
@@ -124,6 +127,7 @@ export class MspConnectionPairSolver extends BaseSolver {
124
127
  const [pin1, pin2] = directlyConnectedPins
125
128
  const p1 = this.pinMap[pin1!]!
126
129
  const p2 = this.pinMap[pin2!]!
130
+ if (!this.canRouteGroundPair(pin1!, pin2!)) return
127
131
  const pinPairKey = getPinPairKey([pin1!, pin2!])
128
132
  // Explicit source traces are classified by straight-line distance when
129
133
  // their input is created; named nets retain the orthogonal route metric.
@@ -195,6 +199,7 @@ export class MspConnectionPairSolver extends BaseSolver {
195
199
  {
196
200
  maxDistance: this.maxMspPairDistance,
197
201
  forbidEdge: (a, b) =>
202
+ !this.canRouteGroundPair(a.pinId, b.pinId) ||
198
203
  arePinsInDifferentSchematicSections(
199
204
  this.inputProblem,
200
205
  a as InputPin & { chipId: string },
@@ -0,0 +1,83 @@
1
+ import { ConnectivityMap } from "connectivity-map"
2
+ import type { InputProblem, PinId } from "lib/types/InputProblem"
3
+ import { getPinDirection } from "../SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/getPinDirection"
4
+ import { getConnectivityMapsFromInputProblem } from "./getConnectivityMapFromInputProblem"
5
+
6
+ // Keep nearby ground connections, but do not extend the usual 1 mm local
7
+ // routing range vertically just because a sheet allows long signal traces.
8
+ const MAX_LOCAL_GROUND_BRANCH_OFFSET = 1
9
+
10
+ /** Avoid return wires between staggered, net-only ground-facing branches. */
11
+ export const getGroundConnectionPolicy = (inputProblem: InputProblem) => {
12
+ const { netConnMap } = getConnectivityMapsFromInputProblem(inputProblem)
13
+ const groundNetId = netConnMap.getNetConnectedToId("GND")
14
+ if (!groundNetId) return () => true
15
+ // Net identifiers do not constitute physical edges: two separate direct
16
+ // connections may have the same netId without requesting a wire between them.
17
+ const physicalConnMap = new ConnectivityMap({})
18
+ for (const connection of inputProblem.directConnections) {
19
+ physicalConnMap.addConnections([connection.pinIds])
20
+ }
21
+ const pins = new Map(
22
+ inputProblem.chips.flatMap((chip) =>
23
+ chip.pins.map((pin) => [pin.pinId, { pin, chip }] as const),
24
+ ),
25
+ )
26
+ const groundFacingPins = [...pins.values()].filter(
27
+ ({ pin, chip }) =>
28
+ chip.pins.length === 2 &&
29
+ netConnMap.getNetConnectedToId(pin.pinId) === groundNetId &&
30
+ (pin._facingDirection ?? getPinDirection(pin, chip)) === "y-",
31
+ )
32
+ // A deliberately wired, level bank of two-pin loads already has its own
33
+ // shared return rail. Do not extend that rail to independent lower branches
34
+ // just to reduce the number of GND symbols. Other ground topologies retain
35
+ // their existing routing behavior.
36
+ const hasExplicitParallelGroundRail = groundFacingPins.some(({ pin: a }) => {
37
+ const group = physicalConnMap.getNetConnectedToId(a.pinId)
38
+ return (
39
+ group !== undefined &&
40
+ groundFacingPins.some(
41
+ ({ pin: b }) =>
42
+ a.pinId !== b.pinId &&
43
+ Math.abs(a.y - b.y) < 1e-6 &&
44
+ Math.abs(a.x - b.x) > 1e-6 &&
45
+ physicalConnMap.getNetConnectedToId(b.pinId) === group,
46
+ )
47
+ )
48
+ })
49
+ if (!hasExplicitParallelGroundRail) return () => true
50
+ const isGroundFacingTerminal = (pinId: PinId) => {
51
+ const entry = pins.get(pinId)
52
+ return (
53
+ entry?.chip.pins.length === 2 &&
54
+ !physicalConnMap.getNetConnectedToId(pinId) &&
55
+ (entry.pin._facingDirection ?? getPinDirection(entry.pin, entry.chip)) ===
56
+ "y-"
57
+ )
58
+ }
59
+
60
+ return (firstPinId: PinId, secondPinId: PinId): boolean => {
61
+ if (
62
+ !groundNetId ||
63
+ netConnMap.getNetConnectedToId(firstPinId) !== groundNetId ||
64
+ netConnMap.getNetConnectedToId(secondPinId) !== groundNetId
65
+ ) {
66
+ return true
67
+ }
68
+ if (
69
+ !isGroundFacingTerminal(firstPinId) &&
70
+ !isGroundFacingTerminal(secondPinId)
71
+ )
72
+ return true
73
+ const first = pins.get(firstPinId)?.pin
74
+ const second = pins.get(secondPinId)?.pin
75
+ // Level ground pins can still form a shared decoupling rail. A staggered
76
+ // terminal would need a return detour; use its local GND label instead.
77
+ return (
78
+ !first ||
79
+ !second ||
80
+ Math.abs(first.y - second.y) <= MAX_LOCAL_GROUND_BRANCH_OFFSET + 1e-6
81
+ )
82
+ }
83
+ }
@@ -10,6 +10,7 @@ import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualize
10
10
  import { getColorFromString } from "lib/utils/getColorFromString"
11
11
  import { getConnectivityMapsFromInputProblem } from "../MspConnectionPairSolver/getConnectivityMapFromInputProblem"
12
12
  import { getNetLabelWidthForConnection } from "lib/utils/getNetLabelWidthForConnection"
13
+ import { getTraceConnectedPinComponents } from "lib/solvers/SchematicTraceLinesSolver/getTraceConnectedPinComponents"
13
14
 
14
15
  /**
15
16
  * A group of traces that have at least one overlapping segment and
@@ -164,41 +165,12 @@ export class NetLabelPlacementSolver extends BaseSolver {
164
165
  ) as string[]
165
166
  const pinsInNet = allIdsInNet.filter((id) => pinIdToPinMap.has(id))
166
167
 
167
- // Build adjacency from solved traces (edges)
168
- const adj: Record<string, Set<string>> = {}
169
- for (const pid of pinsInNet) adj[pid] = new Set()
170
- for (const t of byGlobal[globalConnNetId] ?? []) {
171
- const a = t.pins[0].pinId
172
- const b = t.pins[1].pinId
173
- if (adj[a] && adj[b]) {
174
- adj[a].add(b)
175
- adj[b].add(a)
176
- }
177
- }
178
-
179
- // Find connected components based on trace edges
180
- const visited = new Set<string>()
181
- for (const pid of pinsInNet) {
182
- if (visited.has(pid)) continue
183
- const stack = [pid]
184
- const component = new Set<string>()
185
- visited.add(pid)
186
- while (stack.length > 0) {
187
- const u = stack.pop()!
188
- component.add(u)
189
- for (const v of adj[u] ?? []) {
190
- if (!visited.has(v)) {
191
- visited.add(v)
192
- stack.push(v)
193
- }
194
- }
195
- }
196
-
197
- // Collect traces fully inside this component
198
- const compTraces = (byGlobal[globalConnNetId] ?? []).filter(
199
- (t) =>
200
- component.has(t.pins[0].pinId) && component.has(t.pins[1].pinId),
201
- )
168
+ for (const traceConnectedComponent of getTraceConnectedPinComponents({
169
+ pinIds: pinsInNet,
170
+ traces: byGlobal[globalConnNetId] ?? [],
171
+ })) {
172
+ const component = new Set(traceConnectedComponent.pinIds)
173
+ const compTraces = traceConnectedComponent.traces
202
174
 
203
175
  if (compTraces.length > 0) {
204
176
  // This routed trace exists specifically because two endpoint labels
@@ -6,6 +6,7 @@ import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/
6
6
  import type { InputProblem } from "lib/types/InputProblem"
7
7
  import { getColorFromString } from "lib/utils/getColorFromString"
8
8
  import { alignSameNetJunctions } from "./alignSameNetJunctions"
9
+ import { placeGroundRailLabelsAtOuterEnd } from "./placeGroundRailLabelsAtOuterEnd"
9
10
 
10
11
  interface SameNetJunctionAlignmentSolverInput {
11
12
  inputProblem: InputProblem
@@ -29,7 +30,11 @@ export class SameNetJunctionAlignmentSolver extends BaseSolver {
29
30
  override _step() {
30
31
  const result = alignSameNetJunctions(this.input)
31
32
  this.outputTraces = result.traces
32
- this.outputNetLabelPlacements = result.netLabelPlacements
33
+ this.outputNetLabelPlacements = placeGroundRailLabelsAtOuterEnd({
34
+ inputProblem: this.input.inputProblem,
35
+ traces: result.traces,
36
+ netLabelPlacements: result.netLabelPlacements,
37
+ })
33
38
  this.stats.alignedJunctionCount = result.alignedJunctionCount
34
39
  this.solved = true
35
40
  }