@tscircuit/schematic-trace-solver 0.0.140 → 0.0.142

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.
@@ -3,9 +3,11 @@ import type { GraphicsObject, Rect } from "graphics-debug"
3
3
  import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
4
4
  import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
5
5
  import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
6
+ import { getPinDirection } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/getPinDirection"
6
7
  import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem"
7
8
  import type {
8
9
  InputDirectConnection,
10
+ InputNetConnection,
9
11
  InputProblem,
10
12
  PinId,
11
13
  } from "lib/types/InputProblem"
@@ -15,6 +17,7 @@ import {
15
17
  type AxisAlignedSegment,
16
18
  getAxisAlignedSegments,
17
19
  } from "./getAxisAlignedSegments"
20
+ import { alignPortOnlyInlineNetLabelStubs } from "./alignPortOnlyInlineNetLabelStubs"
18
21
 
19
22
  export const DEFAULT_INLINE_NET_LABEL_HEIGHT = 0.18
20
23
 
@@ -45,9 +48,15 @@ export const INLINE_NET_LABEL_MAX_SPAN_JOG = 0.4
45
48
  export interface InlineNetLabelPlacement {
46
49
  globalConnNetId: string
47
50
  netId?: string
48
- mspPairId: string
51
+ mspPairId?: string
49
52
  pinIds: PinId[]
50
53
 
54
+ /**
55
+ * A generated single-ended trace stub. Present only for an eligible
56
+ * single-pin net connection; routed point-to-point traces omit it.
57
+ */
58
+ stubTracePath?: [Point, Point]
59
+
51
60
  /** Axis the text runs along: "x" reads left-to-right, "y" reads bottom-to-top */
52
61
  axis: "x" | "y"
53
62
 
@@ -96,7 +105,11 @@ export class InlineNetLabelSolver extends BaseSolver {
96
105
  /** Direct connections that opted in, still waiting to be processed */
97
106
  queuedDirectConnections: InputDirectConnection[]
98
107
 
108
+ /** Single-pin net connections that opted in, still waiting to be processed */
109
+ queuedPortOnlyNetConnections: InputNetConnection[]
110
+
99
111
  private tracesByPinPairKey: Map<string, SolvedTracePath[]>
112
+ private hasAlignedPortOnlyStubs = false
100
113
 
101
114
  constructor(input: InlineNetLabelSolverInput) {
102
115
  super()
@@ -107,6 +120,9 @@ export class InlineNetLabelSolver extends BaseSolver {
107
120
  this.queuedDirectConnections = this.inputProblem.directConnections.filter(
108
121
  (dc) => dc.allowInlineNetLabel && dc.netId,
109
122
  )
123
+ this.queuedPortOnlyNetConnections = this.inputProblem.netConnections.filter(
124
+ (nc) => nc.allowInlineNetLabel && nc.pinIds.length === 1 && nc.netId,
125
+ )
110
126
 
111
127
  this.tracesByPinPairKey = new Map()
112
128
  for (const trace of this.traces) {
@@ -132,15 +148,140 @@ export class InlineNetLabelSolver extends BaseSolver {
132
148
 
133
149
  override _step() {
134
150
  const directConnection = this.queuedDirectConnections.shift()
135
- if (!directConnection) {
136
- this.solved = true
137
- this.stats.inlineNetLabelCount = this.inlineNetLabelPlacements.length
151
+ if (directConnection) {
152
+ const placement = this.computeInlinePlacement(directConnection)
153
+ if (placement) {
154
+ this.inlineNetLabelPlacements.push(placement)
155
+ }
156
+ return
157
+ }
158
+
159
+ const portOnlyNetConnection = this.queuedPortOnlyNetConnections.shift()
160
+ if (portOnlyNetConnection) {
161
+ const placement = this.computePortOnlyInlinePlacement(
162
+ portOnlyNetConnection,
163
+ )
164
+ if (placement) {
165
+ this.inlineNetLabelPlacements.push(placement)
166
+ }
167
+ return
168
+ }
169
+
170
+ if (!this.hasAlignedPortOnlyStubs) {
171
+ this.inlineNetLabelPlacements = alignPortOnlyInlineNetLabelStubs({
172
+ placements: this.inlineNetLabelPlacements,
173
+ inputProblem: this.inputProblem,
174
+ traces: this.traces,
175
+ netLabelPlacements: this.inputNetLabelPlacements,
176
+ })
177
+ this.hasAlignedPortOnlyStubs = true
138
178
  return
139
179
  }
140
180
 
141
- const placement = this.computeInlinePlacement(directConnection)
142
- if (placement) {
143
- this.inlineNetLabelPlacements.push(placement)
181
+ this.solved = true
182
+ this.stats.inlineNetLabelCount = this.inlineNetLabelPlacements.length
183
+ }
184
+
185
+ /**
186
+ * Converts a conventional port-only anchored placement into an inline label
187
+ * on a generated outward stub. The stub follows the pin's true facing
188
+ * direction; an anchored label may finish in another direction after an
189
+ * elbow, which is not the direction a terminal stub should leave the pin.
190
+ */
191
+ private computePortOnlyInlinePlacement(
192
+ netConnection: InputNetConnection,
193
+ ): InlineNetLabelPlacement | null {
194
+ const [pinId] = netConnection.pinIds
195
+ if (!pinId) return null
196
+
197
+ const anchoredPlacement = this.inputNetLabelPlacements.find(
198
+ (placement) =>
199
+ placement.netId === netConnection.netId &&
200
+ placement.pinIds.length === 1 &&
201
+ placement.pinIds[0] === pinId,
202
+ )
203
+ if (!anchoredPlacement) return null
204
+
205
+ const inputChip = this.inputProblem.chips.find((chip) =>
206
+ chip.pins.some((pin) => pin.pinId === pinId),
207
+ )
208
+ const inputPin = inputChip?.pins.find((pin) => pin.pinId === pinId)
209
+
210
+ const height =
211
+ netConnection.inlineNetLabelHeight ?? DEFAULT_INLINE_NET_LABEL_HEIGHT
212
+ const width =
213
+ netConnection.inlineNetLabelWidth ??
214
+ netConnection.netLabelWidth ??
215
+ estimateInlineNetLabelWidth(netConnection.netId, height)
216
+
217
+ // Leave a small wire tail at both ends of the text so it unmistakably
218
+ // reads as a label on a trace rather than free-standing text.
219
+ const stubLength = Math.max(width + 0.2, 0.6)
220
+ const start = inputPin
221
+ ? { x: inputPin.x, y: inputPin.y }
222
+ : anchoredPlacement.anchorPoint
223
+ const direction =
224
+ inputPin && inputChip
225
+ ? (inputPin._facingDirection ?? getPinDirection(inputPin, inputChip))
226
+ : anchoredPlacement.orientation
227
+ const end: Point =
228
+ direction === "x+"
229
+ ? { x: start.x + stubLength, y: start.y }
230
+ : direction === "x-"
231
+ ? { x: start.x - stubLength, y: start.y }
232
+ : direction === "y+"
233
+ ? { x: start.x, y: start.y + stubLength }
234
+ : { x: start.x, y: start.y - stubLength }
235
+
236
+ const axis: InlineNetLabelPlacement["axis"] =
237
+ direction === "x+" || direction === "x-" ? "x" : "y"
238
+ const side: InlineNetLabelPlacement["side"] = axis === "x" ? "y+" : "x-"
239
+ const anchorPoint = {
240
+ x: (start.x + end.x) / 2,
241
+ y: (start.y + end.y) / 2,
242
+ }
243
+ const offset = height / 2 + INLINE_NET_LABEL_TRACE_MARGIN
244
+ const center: Point =
245
+ side === "y+"
246
+ ? { x: anchorPoint.x, y: anchorPoint.y + offset }
247
+ : { x: anchorPoint.x - offset, y: anchorPoint.y }
248
+
249
+ const halfAlong = width / 2
250
+ const halfAcross = height / 2
251
+ const bounds: Bounds =
252
+ axis === "x"
253
+ ? {
254
+ minX: center.x - halfAlong,
255
+ maxX: center.x + halfAlong,
256
+ minY: center.y - halfAcross,
257
+ maxY: center.y + halfAcross,
258
+ }
259
+ : {
260
+ minX: center.x - halfAcross,
261
+ maxX: center.x + halfAcross,
262
+ minY: center.y - halfAlong,
263
+ maxY: center.y + halfAlong,
264
+ }
265
+
266
+ if (
267
+ this.isObstructed(bounds, {
268
+ ownGlobalConnNetId: anchoredPlacement.globalConnNetId,
269
+ })
270
+ ) {
271
+ return null
272
+ }
273
+
274
+ return {
275
+ globalConnNetId: anchoredPlacement.globalConnNetId,
276
+ netId: netConnection.netId,
277
+ pinIds: [pinId],
278
+ stubTracePath: [start, end],
279
+ axis,
280
+ anchorPoint,
281
+ center,
282
+ width,
283
+ height,
284
+ side,
144
285
  }
145
286
  }
146
287
 
@@ -209,7 +350,13 @@ export class InlineNetLabelSolver extends BaseSolver {
209
350
  maxY: center.y + halfAlong,
210
351
  }
211
352
 
212
- if (this.isObstructed(bounds, trace)) continue
353
+ if (
354
+ this.isObstructed(bounds, {
355
+ ownTrace: trace,
356
+ ownGlobalConnNetId: trace.globalConnNetId,
357
+ })
358
+ )
359
+ continue
213
360
 
214
361
  return {
215
362
  globalConnNetId: trace.globalConnNetId,
@@ -334,7 +481,13 @@ export class InlineNetLabelSolver extends BaseSolver {
334
481
  maxY: center.y + halfAlong,
335
482
  }
336
483
 
337
- if (this.isObstructed(bounds, trace)) continue
484
+ if (
485
+ this.isObstructed(bounds, {
486
+ ownTrace: trace,
487
+ ownGlobalConnNetId: trace.globalConnNetId,
488
+ })
489
+ )
490
+ continue
338
491
 
339
492
  const anchorPoint: Point =
340
493
  axis === "x"
@@ -363,7 +516,16 @@ export class InlineNetLabelSolver extends BaseSolver {
363
516
  * An inline label may not sit on top of a chip, a component's text, or a
364
517
  * trace belonging to another net.
365
518
  */
366
- private isObstructed(bounds: Bounds, ownTrace: SolvedTracePath): boolean {
519
+ private isObstructed(
520
+ bounds: Bounds,
521
+ {
522
+ ownTrace,
523
+ ownGlobalConnNetId,
524
+ }: {
525
+ ownTrace?: SolvedTracePath
526
+ ownGlobalConnNetId: string
527
+ },
528
+ ): boolean {
367
529
  for (const chip of this.inputProblem.chips) {
368
530
  const chipBounds: Bounds = {
369
531
  minX: chip.center.x - chip.width / 2,
@@ -379,8 +541,8 @@ export class InlineNetLabelSolver extends BaseSolver {
379
541
  }
380
542
 
381
543
  for (const trace of this.traces) {
382
- if (trace.mspPairId === ownTrace.mspPairId) continue
383
- if (trace.globalConnNetId === ownTrace.globalConnNetId) continue
544
+ if (ownTrace && trace.mspPairId === ownTrace.mspPairId) continue
545
+ if (trace.globalConnNetId === ownGlobalConnNetId) continue
384
546
  if (doesPathIntersectBounds(trace.tracePath, bounds)) return true
385
547
  }
386
548
 
@@ -399,10 +561,23 @@ export class InlineNetLabelSolver extends BaseSolver {
399
561
  return keys
400
562
  }
401
563
 
564
+ private getOutputTraces(supersededNetLabelKeys: Set<string>) {
565
+ return this.traces.filter(
566
+ (trace) =>
567
+ !(
568
+ supersededNetLabelKeys.has(trace.globalConnNetId) &&
569
+ trace.mspPairId.startsWith("available-net-orientation-")
570
+ ),
571
+ )
572
+ }
573
+
402
574
  getOutput() {
403
575
  const superseded = this.getSupersededNetLabelKeys()
404
576
  return {
405
- traces: this.traces,
577
+ // AvailableNetOrientationSolver may have routed an elbow from a port to
578
+ // the anchored label that this inline placement supersedes. Keep the
579
+ // actual net trace, but discard that now-orphaned label connector.
580
+ traces: this.getOutputTraces(superseded),
406
581
  netLabelPlacements: this.inputNetLabelPlacements.filter(
407
582
  (placement) => !superseded.has(placement.globalConnNetId),
408
583
  ),
@@ -420,7 +595,8 @@ export class InlineNetLabelSolver extends BaseSolver {
420
595
  graphics.points ??= []
421
596
  graphics.texts ??= []
422
597
 
423
- for (const trace of this.traces) {
598
+ const output = this.getOutput()
599
+ for (const trace of output.traces) {
424
600
  graphics.lines.push({
425
601
  points: trace.tracePath,
426
602
  strokeColor: "purple",
@@ -446,6 +622,12 @@ export class InlineNetLabelSolver extends BaseSolver {
446
622
  }
447
623
 
448
624
  for (const inlineLabel of this.inlineNetLabelPlacements) {
625
+ if (inlineLabel.stubTracePath) {
626
+ graphics.lines.push({
627
+ points: inlineLabel.stubTracePath,
628
+ strokeColor: "purple",
629
+ })
630
+ }
449
631
  const isHorizontal = inlineLabel.axis === "x"
450
632
  graphics.rects.push({
451
633
  center: inlineLabel.center,
@@ -460,12 +642,29 @@ export class InlineNetLabelSolver extends BaseSolver {
460
642
  ].join("\n"),
461
643
  } as Rect & { strokeColor: string })
462
644
  graphics.texts.push({
463
- x: inlineLabel.center.x,
464
- y: inlineLabel.center.y,
645
+ x:
646
+ inlineLabel.stubTracePath && inlineLabel.axis === "x"
647
+ ? inlineLabel.center.x +
648
+ (inlineLabel.stubTracePath[1].x > inlineLabel.stubTracePath[0].x
649
+ ? -inlineLabel.width / 2
650
+ : inlineLabel.width / 2)
651
+ : inlineLabel.center.x,
652
+ y:
653
+ inlineLabel.stubTracePath && inlineLabel.axis === "y"
654
+ ? inlineLabel.center.y +
655
+ (inlineLabel.stubTracePath[1].y > inlineLabel.stubTracePath[0].y
656
+ ? -inlineLabel.width / 2
657
+ : inlineLabel.width / 2)
658
+ : inlineLabel.center.y,
465
659
  text: inlineLabel.netId ?? "",
466
660
  color: "green",
467
661
  fontSize: inlineLabel.height,
468
- anchorSide: "center",
662
+ anchorSide: inlineLabel.stubTracePath
663
+ ? inlineLabel.stubTracePath[1][inlineLabel.axis] >
664
+ inlineLabel.stubTracePath[0][inlineLabel.axis]
665
+ ? "center_left"
666
+ : "center_right"
667
+ : "center",
469
668
  // Vertical labels read bottom-to-top, alongside the wire they name.
470
669
  rotation: inlineLabel.axis === "y" ? 90 : undefined,
471
670
  })
@@ -0,0 +1,233 @@
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 getStubDirection = (path: [Point, Point]): StubDirection | undefined => {
11
+ const [start, end] = path
12
+ if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) {
13
+ return end.x >= start.x ? "x+" : "x-"
14
+ }
15
+ return end.y >= start.y ? "y+" : "y-"
16
+ }
17
+
18
+ const getStubLength = (path: [Point, Point]) => {
19
+ const [start, end] = path
20
+ return Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
21
+ }
22
+
23
+ const getLabelBounds = (placement: {
24
+ center: Point
25
+ width: number
26
+ height: number
27
+ axis?: "x" | "y"
28
+ }): Bounds => {
29
+ const isVertical = placement.axis === "y"
30
+ const width = isVertical ? placement.height : placement.width
31
+ const height = isVertical ? placement.width : placement.height
32
+ return {
33
+ minX: placement.center.x - width / 2,
34
+ maxX: placement.center.x + width / 2,
35
+ minY: placement.center.y - height / 2,
36
+ maxY: placement.center.y + height / 2,
37
+ }
38
+ }
39
+
40
+ const getPathBounds = (path: Point[]): Bounds => ({
41
+ minX: Math.min(...path.map((point) => point.x)),
42
+ maxX: Math.max(...path.map((point) => point.x)),
43
+ minY: Math.min(...path.map((point) => point.y)),
44
+ maxY: Math.max(...path.map((point) => point.y)),
45
+ })
46
+
47
+ const doesPathIntersectBounds = (path: Point[], bounds: Bounds): boolean => {
48
+ for (let index = 0; index < path.length - 1; index++) {
49
+ const segmentBounds = getPathBounds([path[index]!, path[index + 1]!])
50
+ if (boundsOverlap(segmentBounds, bounds)) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ const resizeStub = (
56
+ placement: InlineNetLabelPlacement,
57
+ direction: StubDirection,
58
+ targetLength: number,
59
+ ): InlineNetLabelPlacement => {
60
+ const [start] = placement.stubTracePath!
61
+ const end: Point =
62
+ direction === "x+"
63
+ ? { x: start.x + targetLength, y: start.y }
64
+ : direction === "x-"
65
+ ? { x: start.x - targetLength, y: start.y }
66
+ : direction === "y+"
67
+ ? { x: start.x, y: start.y + targetLength }
68
+ : { x: start.x, y: start.y - targetLength }
69
+ const anchorPoint = {
70
+ x: (start.x + end.x) / 2,
71
+ y: (start.y + end.y) / 2,
72
+ }
73
+
74
+ return {
75
+ ...placement,
76
+ stubTracePath: [start, end],
77
+ anchorPoint,
78
+ // Extending a short row only adds wire at its free end. Keep the label
79
+ // itself next to the pin so terminal labels can be start/end aligned and
80
+ // collision checks continue to describe the rendered text box.
81
+ center: placement.center,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Aligns the free ends of port-only inline-label stubs on each component side.
87
+ *
88
+ * A group is changed atomically: if any longer stub or retained label would
89
+ * collide with a chip, component text, routed trace, retained anchored label,
90
+ * or another inline label/stub, every member keeps its original length.
91
+ */
92
+ export const alignPortOnlyInlineNetLabelStubs = ({
93
+ placements,
94
+ inputProblem,
95
+ traces,
96
+ netLabelPlacements,
97
+ }: {
98
+ placements: InlineNetLabelPlacement[]
99
+ inputProblem: InputProblem
100
+ traces: SolvedTracePath[]
101
+ netLabelPlacements: NetLabelPlacement[]
102
+ }): InlineNetLabelPlacement[] => {
103
+ const chipIdByPinId = new Map<string, string>()
104
+ for (const chip of inputProblem.chips) {
105
+ for (const pin of chip.pins) chipIdByPinId.set(pin.pinId, chip.chipId)
106
+ }
107
+
108
+ const groups = new Map<
109
+ string,
110
+ Array<{
111
+ placementIndex: number
112
+ placement: InlineNetLabelPlacement
113
+ direction: StubDirection
114
+ ownerChipId?: string
115
+ }>
116
+ >()
117
+ for (const [placementIndex, placement] of placements.entries()) {
118
+ if (!placement.stubTracePath || placement.pinIds.length !== 1) continue
119
+ const direction = getStubDirection(placement.stubTracePath)
120
+ if (!direction) continue
121
+ const ownerChipId = chipIdByPinId.get(placement.pinIds[0]!)
122
+ const groupKey = `${ownerChipId ?? placement.pinIds[0]}::${direction}`
123
+ const group = groups.get(groupKey) ?? []
124
+ group.push({ placementIndex, placement, direction, ownerChipId })
125
+ groups.set(groupKey, group)
126
+ }
127
+
128
+ const alignedPlacements = [...placements]
129
+ const supersededGlobalNetIds = new Set(
130
+ placements.map((placement) => placement.globalConnNetId),
131
+ )
132
+ const retainedAnchoredLabelBounds = netLabelPlacements
133
+ .filter(
134
+ (placement) => !supersededGlobalNetIds.has(placement.globalConnNetId),
135
+ )
136
+ .map(getLabelBounds)
137
+
138
+ for (const group of groups.values()) {
139
+ if (group.length < 2) continue
140
+ const targetLength = Math.max(
141
+ ...group.map(({ placement }) => getStubLength(placement.stubTracePath!)),
142
+ )
143
+ const proposals = group.map(({ placement, direction }) =>
144
+ resizeStub(placement, direction, targetLength),
145
+ )
146
+ const groupPlacementIndices = new Set(
147
+ group.map(({ placementIndex }) => placementIndex),
148
+ )
149
+ const fixedInlinePlacements = placements.filter(
150
+ (_, placementIndex) => !groupPlacementIndices.has(placementIndex),
151
+ )
152
+
153
+ const hasConflict = proposals.some((proposal, proposalIndex) => {
154
+ const labelBounds = getLabelBounds(proposal)
155
+ const stubPath = proposal.stubTracePath!
156
+ const stubBounds = getPathBounds(stubPath)
157
+ const ownerChipId = group[proposalIndex]!.ownerChipId
158
+
159
+ for (const chip of inputProblem.chips) {
160
+ const chipBounds: Bounds = {
161
+ minX: chip.center.x - chip.width / 2,
162
+ maxX: chip.center.x + chip.width / 2,
163
+ minY: chip.center.y - chip.height / 2,
164
+ maxY: chip.center.y + chip.height / 2,
165
+ }
166
+ if (boundsOverlap(labelBounds, chipBounds)) return true
167
+ if (
168
+ chip.chipId !== ownerChipId &&
169
+ boundsOverlap(stubBounds, chipBounds)
170
+ )
171
+ return true
172
+ }
173
+
174
+ for (const textBox of inputProblem.textBoxes ?? []) {
175
+ const textBounds = getTextBoxBounds(textBox)
176
+ if (
177
+ boundsOverlap(labelBounds, textBounds) ||
178
+ boundsOverlap(stubBounds, textBounds)
179
+ )
180
+ return true
181
+ }
182
+
183
+ for (const trace of traces) {
184
+ if (trace.globalConnNetId === proposal.globalConnNetId) continue
185
+ if (
186
+ doesPathIntersectBounds(trace.tracePath, labelBounds) ||
187
+ doesPathIntersectBounds(trace.tracePath, stubBounds)
188
+ )
189
+ return true
190
+ }
191
+
192
+ if (
193
+ retainedAnchoredLabelBounds.some(
194
+ (bounds) =>
195
+ boundsOverlap(labelBounds, bounds) ||
196
+ boundsOverlap(stubBounds, bounds),
197
+ )
198
+ )
199
+ return true
200
+
201
+ for (const fixedPlacement of fixedInlinePlacements) {
202
+ const fixedLabelBounds = getLabelBounds(fixedPlacement)
203
+ if (
204
+ boundsOverlap(labelBounds, fixedLabelBounds) ||
205
+ boundsOverlap(stubBounds, fixedLabelBounds)
206
+ )
207
+ return true
208
+ if (
209
+ fixedPlacement.stubTracePath &&
210
+ (doesPathIntersectBounds(fixedPlacement.stubTracePath, labelBounds) ||
211
+ doesPathIntersectBounds(stubPath, fixedLabelBounds))
212
+ )
213
+ return true
214
+ }
215
+
216
+ for (const [otherIndex, otherProposal] of proposals.entries()) {
217
+ if (otherIndex === proposalIndex) continue
218
+ const otherLabelBounds = getLabelBounds(otherProposal)
219
+ if (boundsOverlap(labelBounds, otherLabelBounds)) return true
220
+ if (doesPathIntersectBounds(stubPath, otherLabelBounds)) return true
221
+ }
222
+
223
+ return false
224
+ })
225
+
226
+ if (hasConflict) continue
227
+ for (const [groupIndex, { placementIndex }] of group.entries()) {
228
+ alignedPlacements[placementIndex] = proposals[groupIndex]!
229
+ }
230
+ }
231
+
232
+ return alignedPlacements
233
+ }
@@ -11,6 +11,9 @@ import {
11
11
  import type { MspConnectionPairId } from "../MspConnectionPairSolver/MspConnectionPairSolver"
12
12
 
13
13
  type ConnNetId = string
14
+ type TraceState = Record<MspConnectionPairId, SolvedTracePath>
15
+
16
+ const TRACE_STATE_POSITION_EPSILON = 1e-6
14
17
 
15
18
  /**
16
19
  * This solver finds traces that overlap or meet collinearly and aren't
@@ -44,6 +47,9 @@ export class TraceOverlapShiftSolver extends BaseSolver {
44
47
  traceNetIslands: Record<ConnNetId, Array<SolvedTracePath>> = {}
45
48
 
46
49
  correctedTraceMap: Record<MspConnectionPairId, SolvedTracePath> = {}
50
+ // Keep only the current and previous layouts to detect a two-state cycle
51
+ // without accumulating routing history for the whole solve.
52
+ recentTraceStates: TraceState[] = []
47
53
 
48
54
  cleanupPhase: "diagonals" | "done" | null = null
49
55
 
@@ -64,6 +70,8 @@ export class TraceOverlapShiftSolver extends BaseSolver {
64
70
  this.correctedTraceMap[mspPairId] = tracePath
65
71
  }
66
72
 
73
+ this.rememberTraceState(this.correctedTraceMap)
74
+
67
75
  this.traceNetIslands = this.computeTraceNetIslands()
68
76
  }
69
77
 
@@ -361,11 +369,19 @@ export class TraceOverlapShiftSolver extends BaseSolver {
361
369
 
362
370
  override _step() {
363
371
  if (this.activeSubSolver?.solved) {
364
- for (const [mspPairId, newTrace] of Object.entries(
365
- this.activeSubSolver.correctedTraceMap,
366
- )) {
367
- this.correctedTraceMap[mspPairId] = newTrace
372
+ const nextTraceState = {
373
+ ...this.correctedTraceMap,
374
+ ...this.activeSubSolver.correctedTraceMap,
368
375
  }
376
+ // Returning to the older retained layout means corrections are bouncing
377
+ // A -> B -> A. Keep B and finish this pass instead of retrying forever.
378
+ if (this.returnsToPreviousTraceState(nextTraceState)) {
379
+ this.activeSubSolver = null
380
+ this.solved = true
381
+ return
382
+ }
383
+ this.correctedTraceMap = nextTraceState
384
+ this.rememberTraceState(nextTraceState)
369
385
  this.activeSubSolver = null
370
386
  this.traceNetIslands = this.computeTraceNetIslands()
371
387
  }
@@ -407,6 +423,46 @@ export class TraceOverlapShiftSolver extends BaseSolver {
407
423
  })
408
424
  }
409
425
 
426
+ private rememberTraceState(traceState: TraceState) {
427
+ this.recentTraceStates.push({ ...traceState })
428
+ if (this.recentTraceStates.length > 2) {
429
+ this.recentTraceStates.shift()
430
+ }
431
+ }
432
+
433
+ private returnsToPreviousTraceState(candidateTraceState: TraceState) {
434
+ if (this.recentTraceStates.length < 2) return false
435
+
436
+ const previousTraceState = this.recentTraceStates[0]!
437
+ const traceIds = Object.keys(candidateTraceState)
438
+ if (traceIds.length !== Object.keys(previousTraceState).length) return false
439
+
440
+ // Corrections create new objects, so compare the trace geometry itself.
441
+ for (const traceId of traceIds) {
442
+ const candidatePath = candidateTraceState[traceId]
443
+ const previousPath = previousTraceState[traceId]
444
+ if (!candidatePath || !previousPath) return false
445
+ if (candidatePath.tracePath.length !== previousPath.tracePath.length) {
446
+ return false
447
+ }
448
+
449
+ for (let i = 0; i < candidatePath.tracePath.length; i++) {
450
+ const candidatePoint = candidatePath.tracePath[i]!
451
+ const previousPoint = previousPath.tracePath[i]!
452
+ if (
453
+ Math.abs(candidatePoint.x - previousPoint.x) >
454
+ TRACE_STATE_POSITION_EPSILON ||
455
+ Math.abs(candidatePoint.y - previousPoint.y) >
456
+ TRACE_STATE_POSITION_EPSILON
457
+ ) {
458
+ return false
459
+ }
460
+ }
461
+ }
462
+
463
+ return true
464
+ }
465
+
410
466
  override visualize() {
411
467
  if (this.activeSubSolver) {
412
468
  return this.activeSubSolver.visualize()
@@ -65,6 +65,19 @@ export interface InputNetConnection {
65
65
  pinIds: Array<PinId>
66
66
  netLabelWidth?: number
67
67
  netLabelHeight?: number
68
+
69
+ /**
70
+ * When true, a named single-pin net may be drawn as a short outward trace
71
+ * stub with its net name placed inline. Multi-pin net connections retain the
72
+ * regular anchored-label behavior.
73
+ */
74
+ allowInlineNetLabel?: boolean
75
+
76
+ /** Extent of the inline text along the generated trace stub. */
77
+ inlineNetLabelWidth?: number
78
+
79
+ /** Height of the inline text perpendicular to the generated trace stub. */
80
+ inlineNetLabelHeight?: number
68
81
  }
69
82
 
70
83
  export interface InputProblem {
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "https://github.com/tscircuit/schematic-trace-solver.git"
6
6
  },
7
7
  "main": "dist/index.js",
8
- "version": "0.0.140",
8
+ "version": "0.0.142",
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "start": "cosmos",