@tscircuit/schematic-trace-solver 0.0.101 → 0.0.103

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 (37) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +251 -66
  3. package/lib/solvers/AvailableNetOrientationSolver/AvailableNetOrientationSolver.ts +58 -3
  4. package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2.ts +13 -5
  5. package/lib/solvers/TraceOverlapShiftSolver/TraceOverlapIssueSolver/TraceOverlapIssueSolver.ts +168 -24
  6. package/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts +128 -48
  7. package/package.json +1 -1
  8. package/site/bug-reports/bug-report-20260716T144856Z.page.tsx +4 -0
  9. package/site/bug-reports/bug-report-20260717T022934Z.page.tsx +4 -0
  10. package/site/bug-reports/bug-report-20260717T031704Z.page.tsx +4 -0
  11. package/site/bug-reports/bug-report-20260717T042845Z.page.tsx +4 -0
  12. package/tests/bug-reports/bug-report-20260706T213649Z/__snapshots__/bug-report-20260706T213649Z.snap.svg +3 -3
  13. package/tests/bug-reports/bug-report-20260706T220324Z/__snapshots__/bug-report-20260706T220324Z.snap.svg +3 -3
  14. package/tests/bug-reports/bug-report-20260707T092615Z/__snapshots__/bug-report-20260707T092615Z.snap.svg +5 -5
  15. package/tests/bug-reports/bug-report-20260707T140410Z/__snapshots__/bug-report-20260707T140410Z.snap.svg +3 -3
  16. package/tests/bug-reports/bug-report-20260716T144856Z/__snapshots__/bug-report-20260716T144856Z.snap.svg +149 -0
  17. package/tests/bug-reports/bug-report-20260716T144856Z/bug-report-20260716T144856Z.json +693 -0
  18. package/tests/bug-reports/bug-report-20260716T144856Z/bug-report-20260716T144856Z.test.ts +12 -0
  19. package/tests/bug-reports/bug-report-20260717T022934Z/__snapshots__/bug-report-20260717T022934Z.snap.svg +130 -0
  20. package/tests/bug-reports/bug-report-20260717T022934Z/bug-report-20260717T022934Z.json +543 -0
  21. package/tests/bug-reports/bug-report-20260717T022934Z/bug-report-20260717T022934Z.test.ts +12 -0
  22. package/tests/bug-reports/bug-report-20260717T031704Z/__snapshots__/bug-report-20260717T031704Z.snap.svg +48 -0
  23. package/tests/bug-reports/bug-report-20260717T031704Z/bug-report-20260717T031704Z.json +77 -0
  24. package/tests/bug-reports/bug-report-20260717T031704Z/bug-report-20260717T031704Z.test.ts +12 -0
  25. package/tests/bug-reports/bug-report-20260717T042845Z/__snapshots__/bug-report-20260717T042845Z.snap.svg +54 -0
  26. package/tests/bug-reports/bug-report-20260717T042845Z/bug-report-20260717T042845Z.json +113 -0
  27. package/tests/bug-reports/bug-report-20260717T042845Z/bug-report-20260717T042845Z.test.ts +12 -0
  28. package/tests/examples/__snapshots__/example11.snap.svg +19 -19
  29. package/tests/examples/__snapshots__/example19.snap.svg +3 -5
  30. package/tests/examples/__snapshots__/example29.snap.svg +13 -13
  31. package/tests/examples/__snapshots__/example37.snap.svg +4 -2
  32. package/tests/examples/example16.test.ts +22 -0
  33. package/tests/repros/__snapshots__/repro-rp2040-gamepad-trace-alignment.snap.svg +2 -8
  34. package/tests/repros/__snapshots__/repro47-endpoint-obstacle-detour.snap.svg +19 -19
  35. package/tests/repros/__snapshots__/trace-overlap-box-resistor.snap.svg +10 -10
  36. package/tests/repros/repro47-endpoint-obstacle-detour.test.ts +30 -0
  37. package/tests/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.test.ts +174 -0
package/dist/index.d.ts CHANGED
@@ -239,8 +239,10 @@ interface OverlappingTraceSegmentLocator {
239
239
  traceSegmentIndex: number;
240
240
  }>;
241
241
  }
242
+ type TraceInteractionKind = "overlap" | "point_contact";
242
243
  declare class TraceOverlapIssueSolver extends BaseSolver {
243
244
  overlappingTraceSegments: OverlappingTraceSegmentLocator[];
245
+ interactionKind: TraceInteractionKind;
244
246
  traceNetIslands: Record<ConnNetId$1, Array<SolvedTracePath>>;
245
247
  obstacleRects: ReturnType<typeof getObstacleRects>;
246
248
  SHIFT_DISTANCE: number;
@@ -248,9 +250,12 @@ declare class TraceOverlapIssueSolver extends BaseSolver {
248
250
  constructor(params: {
249
251
  inputProblem: InputProblem;
250
252
  overlappingTraceSegments: OverlappingTraceSegmentLocator[];
253
+ interactionKind: TraceInteractionKind;
251
254
  traceNetIslands: Record<ConnNetId$1, Array<SolvedTracePath>>;
252
255
  });
253
256
  _step(): void;
257
+ private applyOffsets;
258
+ private countDifferentNetIntersections;
254
259
  private getObstacleAwareOffset;
255
260
  private getBlockingCollisions;
256
261
  visualize(): GraphicsObject;
@@ -258,14 +263,15 @@ declare class TraceOverlapIssueSolver extends BaseSolver {
258
263
 
259
264
  type ConnNetId = string;
260
265
  /**
261
- * This solver finds traces that overlap (coincident and parallel) and aren't
262
- * connected via the globalConnMap, then shifts them to avoid the overlap in
263
- * such a way that minimizes the resulting intersections
266
+ * This solver finds traces that overlap or meet collinearly and aren't
267
+ * connected via the globalConnMap, then shifts them apart in the direction
268
+ * that minimizes the resulting intersections.
264
269
  *
265
270
  * All traces are orthogonal, so for traces to be considered overlapping, they
266
- * need to each have a segment where both are horizontal or both are vertical
267
- * AND the segments must be within 1e-6 of each other in X (if vertical) or
268
- * Y (if horizontal)
271
+ * need to each have a segment where both are horizontal or both are vertical,
272
+ * the segments must be on the same axis, and their ranges must overlap. A
273
+ * point contact is also handled when both segments are internal rails, where
274
+ * it can be separated without moving an anchored terminal segment.
269
275
  *
270
276
  * Each iteration, we find overlapping traces that aren't part of the same net.
271
277
  * This is the same as finding two "trace net islands" that have an overlap.
@@ -294,6 +300,7 @@ declare class TraceOverlapShiftSolver extends BaseSolver {
294
300
  computeTraceNetIslands(): Record<ConnNetId, Array<SolvedTracePath>>;
295
301
  findNextOverlapIssue(): {
296
302
  overlappingTraceSegments: Array<OverlappingTraceSegmentLocator>;
303
+ interactionKind: TraceInteractionKind;
297
304
  } | null;
298
305
  private findNextDiagonalSegment;
299
306
  private findAndFixNextDiagonalSegment;
@@ -753,6 +760,7 @@ declare class AvailableNetOrientationSolver extends BaseSolver {
753
760
  private finish;
754
761
  private setCurrentLabel;
755
762
  private findCorrectedCandidate;
763
+ private hasTraceContinuingInOrientation;
756
764
  private findValidRotatedCandidate;
757
765
  private findValidTraceAnchorCandidate;
758
766
  private getTraceAnchorCandidatePoints;
package/dist/index.js CHANGED
@@ -1291,6 +1291,7 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
1291
1291
  const isEndpointChipObstacle = rect.kind === "chip" && this.pins.some((pin) => pin.chipId === rect.chipId);
1292
1292
  const canGenerateEndpointDetour = path.length === 3 || path.length === 4 && this.connectionPair !== void 0 && !isEndpointChipObstacle;
1293
1293
  if (canGenerateEndpointDetour && (isFirstSegment || isLastSegment)) {
1294
+ const compareDetours = path.length === 4 ? (a2, b2) => this.getPinBandPenalty(a2) - this.getPinBandPenalty(b2) || this.pathLength(a2) - this.pathLength(b2) : (a2, b2) => this.pathLength(a2) - this.pathLength(b2) || this.getPinBandPenalty(a2) - this.getPinBandPenalty(b2);
1294
1295
  const detours = generateEndpointCollisionDetours({
1295
1296
  path,
1296
1297
  collidingSegmentIndex: segIndex,
@@ -1300,9 +1301,7 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
1300
1301
  if (this.visited.has(key)) return false;
1301
1302
  this.visited.add(key);
1302
1303
  return true;
1303
- }).sort(
1304
- (a2, b2) => this.pathLength(a2) - this.pathLength(b2) || this.getPinBandPenalty(a2) - this.getPinBandPenalty(b2)
1305
- );
1304
+ }).sort(compareDetours);
1306
1305
  for (const detour of detours) {
1307
1306
  const nextCollisionRects = new Set(collisionRects);
1308
1307
  nextCollisionRects.add(rect);
@@ -1542,6 +1541,9 @@ var SchematicTraceLinesSolver = class extends BaseSolver {
1542
1541
  }
1543
1542
  };
1544
1543
 
1544
+ // lib/solvers/TraceOverlapShiftSolver/TraceOverlapIssueSolver/TraceOverlapIssueSolver.ts
1545
+ import { doSegmentsIntersect } from "@tscircuit/math-utils";
1546
+
1545
1547
  // lib/solvers/TraceOverlapShiftSolver/TraceOverlapIssueSolver/applyJogToTrace.ts
1546
1548
  var applyJogToTerminalSegment = ({
1547
1549
  pts,
@@ -1630,6 +1632,7 @@ var applyJogToTerminalSegment = ({
1630
1632
  var EPS4 = 1e-6;
1631
1633
  var TraceOverlapIssueSolver = class extends BaseSolver {
1632
1634
  overlappingTraceSegments;
1635
+ interactionKind;
1633
1636
  traceNetIslands;
1634
1637
  obstacleRects;
1635
1638
  SHIFT_DISTANCE = 0.1;
@@ -1637,6 +1640,7 @@ var TraceOverlapIssueSolver = class extends BaseSolver {
1637
1640
  constructor(params) {
1638
1641
  super();
1639
1642
  this.overlappingTraceSegments = params.overlappingTraceSegments;
1643
+ this.interactionKind = params.interactionKind;
1640
1644
  this.traceNetIslands = params.traceNetIslands;
1641
1645
  this.obstacleRects = getObstacleRects(params.inputProblem);
1642
1646
  for (const { connNetId, pathsWithOverlap } of this.overlappingTraceSegments) {
@@ -1660,19 +1664,64 @@ var TraceOverlapIssueSolver = class extends BaseSolver {
1660
1664
  const someGroupCanShift = groupShouldStayInPlace.some(
1661
1665
  (shouldStay) => !shouldStay
1662
1666
  );
1663
- const offsets = this.overlappingTraceSegments.map((group, idx) => {
1664
- if (someGroupCanShift && groupShouldStayInPlace[idx]) return 0;
1665
- const n = Math.floor(idx / 2) + 1;
1666
- const signed = idx % 2 === 0 ? -n : n;
1667
+ const getSeparationOffsets = (firstDirection) => this.overlappingTraceSegments.map((group, groupIndex) => {
1668
+ if (someGroupCanShift && groupShouldStayInPlace[groupIndex]) return 0;
1669
+ const n = Math.floor(groupIndex / 2) + 1;
1670
+ const direction = groupIndex % 2 === 0 ? firstDirection : -firstDirection;
1667
1671
  return this.getObstacleAwareOffset({
1668
1672
  group,
1669
- offset: signed * this.SHIFT_DISTANCE
1673
+ offset: direction * n * this.SHIFT_DISTANCE
1670
1674
  });
1671
1675
  });
1676
+ const establishedOffsets = getSeparationOffsets(-1);
1677
+ const isCompoundOverlap = this.interactionKind === "overlap" && this.overlappingTraceSegments.some(
1678
+ (group) => group.pathsWithOverlap.length > 1
1679
+ );
1680
+ if (isCompoundOverlap) {
1681
+ this.correctedTraceMap = this.applyOffsets(establishedOffsets);
1682
+ this.solved = true;
1683
+ return;
1684
+ }
1685
+ const offsetCandidates = this.interactionKind === "overlap" ? [establishedOffsets, getSeparationOffsets(1)] : [];
1686
+ if (this.interactionKind === "point_contact") {
1687
+ const movableGroupIndexes = this.overlappingTraceSegments.map((_, groupIndex) => groupIndex).filter(
1688
+ (groupIndex) => !someGroupCanShift || !groupShouldStayInPlace[groupIndex]
1689
+ ).reverse();
1690
+ for (const groupIndex of movableGroupIndexes) {
1691
+ const group = this.overlappingTraceSegments[groupIndex];
1692
+ for (const direction of [-1, 1]) {
1693
+ const offsets = this.overlappingTraceSegments.map(() => 0);
1694
+ offsets[groupIndex] = this.getObstacleAwareOffset({
1695
+ group,
1696
+ offset: direction * this.SHIFT_DISTANCE
1697
+ });
1698
+ offsetCandidates.push(offsets);
1699
+ }
1700
+ }
1701
+ }
1702
+ const candidates = offsetCandidates.map((offsets) => {
1703
+ const correctedTraceMap = this.applyOffsets(offsets);
1704
+ return {
1705
+ correctedTraceMap,
1706
+ intersectionCount: this.countDifferentNetIntersections(correctedTraceMap),
1707
+ totalDisplacement: offsets.reduce(
1708
+ (total, offset) => total + Math.abs(offset),
1709
+ 0
1710
+ )
1711
+ };
1712
+ });
1713
+ candidates.sort(
1714
+ (a, b) => a.intersectionCount - b.intersectionCount || a.totalDisplacement - b.totalDisplacement
1715
+ );
1716
+ this.correctedTraceMap = candidates[0].correctedTraceMap;
1717
+ this.solved = true;
1718
+ }
1719
+ applyOffsets(offsets) {
1720
+ const correctedTraceMap = { ...this.correctedTraceMap };
1672
1721
  const eq = (a, b) => Math.abs(a - b) < EPS4;
1673
1722
  const samePoint = (p, q) => !!p && !!q && eq(p.x, q.x) && eq(p.y, q.y);
1674
- this.overlappingTraceSegments.forEach((group, gidx) => {
1675
- const offset = offsets[gidx];
1723
+ this.overlappingTraceSegments.forEach((group, groupIndex) => {
1724
+ const offset = offsets[groupIndex];
1676
1725
  if (offset === 0) return;
1677
1726
  const byPath = /* @__PURE__ */ new Map();
1678
1727
  for (const loc of group.pathsWithOverlap) {
@@ -1683,9 +1732,22 @@ var TraceOverlapIssueSolver = class extends BaseSolver {
1683
1732
  }
1684
1733
  for (const [pathIdx, segIdxSet] of byPath) {
1685
1734
  const original = this.traceNetIslands[group.connNetId][pathIdx];
1686
- const current = this.correctedTraceMap[original.mspPairId] ?? original;
1735
+ const current = correctedTraceMap[original.mspPairId] ?? original;
1687
1736
  const pts = current.tracePath.map((p) => ({ ...p }));
1688
1737
  const segIdxsRev = Array.from(segIdxSet).sort((a, b) => a - b).reverse();
1738
+ const segmentAxes = /* @__PURE__ */ new Map();
1739
+ for (const si of segIdxsRev) {
1740
+ const start = pts[si];
1741
+ const end = pts[si + 1];
1742
+ if (!start || !end) continue;
1743
+ if (Math.abs(start.x - end.x) < EPS4) {
1744
+ segmentAxes.set(si, "vertical");
1745
+ } else if (Math.abs(start.y - end.y) < EPS4) {
1746
+ segmentAxes.set(si, "horizontal");
1747
+ }
1748
+ }
1749
+ const shiftedXPointIndexes = /* @__PURE__ */ new Set();
1750
+ const shiftedYPointIndexes = /* @__PURE__ */ new Set();
1689
1751
  const JOG_SIZE = this.SHIFT_DISTANCE;
1690
1752
  for (const si of segIdxsRev) {
1691
1753
  if (si < 0 || si >= pts.length - 1) continue;
@@ -1700,15 +1762,25 @@ var TraceOverlapIssueSolver = class extends BaseSolver {
1700
1762
  } else {
1701
1763
  const start = pts[si];
1702
1764
  const end = pts[si + 1];
1703
- const isVertical5 = Math.abs(start.x - end.x) < EPS4;
1704
- const isHorizontal4 = Math.abs(start.y - end.y) < EPS4;
1705
- if (!isVertical5 && !isHorizontal4) continue;
1706
- if (isVertical5) {
1707
- start.x += offset;
1708
- end.x += offset;
1709
- } else {
1710
- start.y += offset;
1711
- end.y += offset;
1765
+ const axis = segmentAxes.get(si);
1766
+ if (axis === "vertical") {
1767
+ if (!shiftedXPointIndexes.has(si)) {
1768
+ start.x += offset;
1769
+ shiftedXPointIndexes.add(si);
1770
+ }
1771
+ if (!shiftedXPointIndexes.has(si + 1)) {
1772
+ end.x += offset;
1773
+ shiftedXPointIndexes.add(si + 1);
1774
+ }
1775
+ } else if (axis === "horizontal") {
1776
+ if (!shiftedYPointIndexes.has(si)) {
1777
+ start.y += offset;
1778
+ shiftedYPointIndexes.add(si);
1779
+ }
1780
+ if (!shiftedYPointIndexes.has(si + 1)) {
1781
+ end.y += offset;
1782
+ shiftedYPointIndexes.add(si + 1);
1783
+ }
1712
1784
  }
1713
1785
  }
1714
1786
  }
@@ -1718,13 +1790,39 @@ var TraceOverlapIssueSolver = class extends BaseSolver {
1718
1790
  cleaned.push(p);
1719
1791
  }
1720
1792
  }
1721
- this.correctedTraceMap[original.mspPairId] = {
1793
+ correctedTraceMap[original.mspPairId] = {
1722
1794
  ...current,
1723
1795
  tracePath: cleaned
1724
1796
  };
1725
1797
  }
1726
1798
  });
1727
- this.solved = true;
1799
+ return correctedTraceMap;
1800
+ }
1801
+ countDifferentNetIntersections(correctedTraceMap) {
1802
+ const traces = Object.values(this.traceNetIslands).flatMap(
1803
+ (island) => island.map((trace) => correctedTraceMap[trace.mspPairId] ?? trace)
1804
+ );
1805
+ let intersectionCount = 0;
1806
+ for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) {
1807
+ const trace = traces[traceIndex];
1808
+ for (let otherTraceIndex = traceIndex + 1; otherTraceIndex < traces.length; otherTraceIndex++) {
1809
+ const otherTrace = traces[otherTraceIndex];
1810
+ if (trace.globalConnNetId === otherTrace.globalConnNetId) continue;
1811
+ for (let segmentIndex = 0; segmentIndex < trace.tracePath.length - 1; segmentIndex++) {
1812
+ for (let otherSegmentIndex = 0; otherSegmentIndex < otherTrace.tracePath.length - 1; otherSegmentIndex++) {
1813
+ if (doSegmentsIntersect(
1814
+ trace.tracePath[segmentIndex],
1815
+ trace.tracePath[segmentIndex + 1],
1816
+ otherTrace.tracePath[otherSegmentIndex],
1817
+ otherTrace.tracePath[otherSegmentIndex + 1]
1818
+ )) {
1819
+ intersectionCount++;
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ }
1825
+ return intersectionCount;
1728
1826
  }
1729
1827
  getObstacleAwareOffset({
1730
1828
  group,
@@ -1886,15 +1984,58 @@ var TraceOverlapShiftSolver = class extends BaseSolver {
1886
1984
  const pathsB = this.traceNetIslands[netB] || [];
1887
1985
  const overlapsA = [];
1888
1986
  const overlapsB = [];
1987
+ const pointContacts = [];
1889
1988
  const seenA = /* @__PURE__ */ new Set();
1890
1989
  const seenB = /* @__PURE__ */ new Set();
1891
- const overlaps1D = (a1, a2, b1, b2) => {
1990
+ const getRangeOverlap1D = (a1, a2, b1, b2) => {
1892
1991
  const minA = Math.min(a1, a2);
1893
1992
  const maxA = Math.max(a1, a2);
1894
1993
  const minB = Math.min(b1, b2);
1895
1994
  const maxB = Math.max(b1, b2);
1896
- const overlap = Math.min(maxA, maxB) - Math.max(minA, minB);
1897
- return overlap > EPS11;
1995
+ return Math.min(maxA, maxB) - Math.max(minA, minB);
1996
+ };
1997
+ const recordCollinearInteraction = ({
1998
+ pathAIndex,
1999
+ segmentAIndex,
2000
+ pathBIndex,
2001
+ segmentBIndex,
2002
+ rangeOverlap
2003
+ }) => {
2004
+ if (rangeOverlap > EPS11) {
2005
+ const keyA = `${pathAIndex}:${segmentAIndex}`;
2006
+ const keyB = `${pathBIndex}:${segmentBIndex}`;
2007
+ if (!seenA.has(keyA)) {
2008
+ overlapsA.push({
2009
+ solvedTracePathIndex: pathAIndex,
2010
+ traceSegmentIndex: segmentAIndex
2011
+ });
2012
+ seenA.add(keyA);
2013
+ }
2014
+ if (!seenB.has(keyB)) {
2015
+ overlapsB.push({
2016
+ solvedTracePathIndex: pathBIndex,
2017
+ traceSegmentIndex: segmentBIndex
2018
+ });
2019
+ seenB.add(keyB);
2020
+ }
2021
+ return;
2022
+ }
2023
+ if (rangeOverlap < -EPS11) return;
2024
+ const pathA = pathsA[pathAIndex].tracePath;
2025
+ const pathB = pathsB[pathBIndex].tracePath;
2026
+ const segmentAStart = pathA[segmentAIndex];
2027
+ const segmentAEnd = pathA[segmentAIndex + 1];
2028
+ const segmentBStart = pathB[segmentBIndex];
2029
+ const segmentBEnd = pathB[segmentBIndex + 1];
2030
+ const hasTerminalSegment = segmentAIndex === 0 || segmentAIndex === pathA.length - 2 || segmentBIndex === 0 || segmentBIndex === pathB.length - 2;
2031
+ if (hasTerminalSegment) return;
2032
+ pointContacts.push({
2033
+ pathAIndex,
2034
+ segmentAIndex,
2035
+ pathBIndex,
2036
+ segmentBIndex,
2037
+ combinedLength: Math.abs(segmentAStart.x - segmentAEnd.x) + Math.abs(segmentAStart.y - segmentAEnd.y) + Math.abs(segmentBStart.x - segmentBEnd.x) + Math.abs(segmentBStart.y - segmentBEnd.y)
2038
+ });
1898
2039
  };
1899
2040
  for (let pa = 0; pa < pathsA.length; pa++) {
1900
2041
  const pathA = pathsA[pa];
@@ -1916,45 +2057,23 @@ var TraceOverlapShiftSolver = class extends BaseSolver {
1916
2057
  if (!bVert && !bHorz) continue;
1917
2058
  if (aVert && bVert) {
1918
2059
  if (Math.abs(a1.x - b1.x) < EPS11) {
1919
- if (overlaps1D(a1.y, a2.y, b1.y, b2.y)) {
1920
- const keyA = `${pa}:${sa}`;
1921
- const keyB = `${pb}:${sb}`;
1922
- if (!seenA.has(keyA)) {
1923
- overlapsA.push({
1924
- solvedTracePathIndex: pa,
1925
- traceSegmentIndex: sa
1926
- });
1927
- seenA.add(keyA);
1928
- }
1929
- if (!seenB.has(keyB)) {
1930
- overlapsB.push({
1931
- solvedTracePathIndex: pb,
1932
- traceSegmentIndex: sb
1933
- });
1934
- seenB.add(keyB);
1935
- }
1936
- }
2060
+ recordCollinearInteraction({
2061
+ pathAIndex: pa,
2062
+ segmentAIndex: sa,
2063
+ pathBIndex: pb,
2064
+ segmentBIndex: sb,
2065
+ rangeOverlap: getRangeOverlap1D(a1.y, a2.y, b1.y, b2.y)
2066
+ });
1937
2067
  }
1938
2068
  } else if (aHorz && bHorz) {
1939
2069
  if (Math.abs(a1.y - b1.y) < EPS11) {
1940
- if (overlaps1D(a1.x, a2.x, b1.x, b2.x)) {
1941
- const keyA = `${pa}:${sa}`;
1942
- const keyB = `${pb}:${sb}`;
1943
- if (!seenA.has(keyA)) {
1944
- overlapsA.push({
1945
- solvedTracePathIndex: pa,
1946
- traceSegmentIndex: sa
1947
- });
1948
- seenA.add(keyA);
1949
- }
1950
- if (!seenB.has(keyB)) {
1951
- overlapsB.push({
1952
- solvedTracePathIndex: pb,
1953
- traceSegmentIndex: sb
1954
- });
1955
- seenB.add(keyB);
1956
- }
1957
- }
2070
+ recordCollinearInteraction({
2071
+ pathAIndex: pa,
2072
+ segmentAIndex: sa,
2073
+ pathBIndex: pb,
2074
+ segmentBIndex: sb,
2075
+ rangeOverlap: getRangeOverlap1D(a1.x, a2.x, b1.x, b2.x)
2076
+ });
1958
2077
  }
1959
2078
  }
1960
2079
  }
@@ -1963,12 +2082,41 @@ var TraceOverlapShiftSolver = class extends BaseSolver {
1963
2082
  }
1964
2083
  if (overlapsA.length > 0 && overlapsB.length > 0) {
1965
2084
  return {
2085
+ interactionKind: "overlap",
1966
2086
  overlappingTraceSegments: [
1967
2087
  { connNetId: netA, pathsWithOverlap: overlapsA },
1968
2088
  { connNetId: netB, pathsWithOverlap: overlapsB }
1969
2089
  ]
1970
2090
  };
1971
2091
  }
2092
+ const bestPointContact = pointContacts.sort(
2093
+ (a, b) => b.combinedLength - a.combinedLength
2094
+ )[0];
2095
+ if (bestPointContact) {
2096
+ return {
2097
+ interactionKind: "point_contact",
2098
+ overlappingTraceSegments: [
2099
+ {
2100
+ connNetId: netA,
2101
+ pathsWithOverlap: [
2102
+ {
2103
+ solvedTracePathIndex: bestPointContact.pathAIndex,
2104
+ traceSegmentIndex: bestPointContact.segmentAIndex
2105
+ }
2106
+ ]
2107
+ },
2108
+ {
2109
+ connNetId: netB,
2110
+ pathsWithOverlap: [
2111
+ {
2112
+ solvedTracePathIndex: bestPointContact.pathBIndex,
2113
+ traceSegmentIndex: bestPointContact.segmentBIndex
2114
+ }
2115
+ ]
2116
+ }
2117
+ ]
2118
+ };
2119
+ }
1972
2120
  }
1973
2121
  }
1974
2122
  return null;
@@ -2044,9 +2192,10 @@ var TraceOverlapShiftSolver = class extends BaseSolver {
2044
2192
  this.solved = true;
2045
2193
  return;
2046
2194
  }
2047
- const { overlappingTraceSegments } = overlapIssue;
2195
+ const { interactionKind, overlappingTraceSegments } = overlapIssue;
2048
2196
  this.activeSubSolver = new TraceOverlapIssueSolver({
2049
2197
  inputProblem: this.inputProblem,
2198
+ interactionKind,
2050
2199
  overlappingTraceSegments,
2051
2200
  traceNetIslands: this.traceNetIslands
2052
2201
  });
@@ -4165,7 +4314,7 @@ var expandChipsToFitPins = (problem) => {
4165
4314
  };
4166
4315
 
4167
4316
  // lib/utils/does-trace-overlap-with-existing-traces.ts
4168
- import { doSegmentsIntersect } from "@tscircuit/math-utils";
4317
+ import { doSegmentsIntersect as doSegmentsIntersect2 } from "@tscircuit/math-utils";
4169
4318
  function doesTraceOverlapWithExistingTraces(newTracePath, existingTraces) {
4170
4319
  for (let i = 0; i < newTracePath.length - 1; i++) {
4171
4320
  const newSegmentP1 = newTracePath[i];
@@ -4174,7 +4323,7 @@ function doesTraceOverlapWithExistingTraces(newTracePath, existingTraces) {
4174
4323
  for (let j = 0; j < existingTrace.tracePath.length - 1; j++) {
4175
4324
  const existingSegmentP1 = existingTrace.tracePath[j];
4176
4325
  const existingSegmentP2 = existingTrace.tracePath[j + 1];
4177
- if (doSegmentsIntersect(
4326
+ if (doSegmentsIntersect2(
4178
4327
  newSegmentP1,
4179
4328
  newSegmentP2,
4180
4329
  existingSegmentP1,
@@ -7335,6 +7484,26 @@ var AvailableNetOrientationSolver = class extends BaseSolver {
7335
7484
  }
7336
7485
  findCorrectedCandidate(label, labelIndex) {
7337
7486
  const orientations = this.getAvailableOrientations(label);
7487
+ const requiredOrientation = orientations[0];
7488
+ const isTwoPinNet = this.inputProblem.netConnections.some(
7489
+ (connection) => connection.netId === label.netId && connection.pinIds.length === 2
7490
+ );
7491
+ if (isTwoPinNet && orientations.length === 1 && isYOrientation(requiredOrientation) && this.hasTraceContinuingInOrientation(label, requiredOrientation)) {
7492
+ const alignedCandidate = this.findValidCandidateInShiftColumn({
7493
+ label,
7494
+ labelIndex,
7495
+ orientation: requiredOrientation,
7496
+ direction: dir(requiredOrientation),
7497
+ baseAnchor: this.getWickOffsetAnchor(
7498
+ label.anchorPoint,
7499
+ requiredOrientation
7500
+ ),
7501
+ maxSearchDistance: this.maxSearchDistance,
7502
+ outwardDistance: 0,
7503
+ stopOnTraceCollision: false
7504
+ });
7505
+ if (alignedCandidate) return alignedCandidate;
7506
+ }
7338
7507
  const rotatedCandidate = this.findValidRotatedCandidate(
7339
7508
  label,
7340
7509
  labelIndex,
@@ -7359,6 +7528,21 @@ var AvailableNetOrientationSolver = class extends BaseSolver {
7359
7528
  labelIndex
7360
7529
  );
7361
7530
  }
7531
+ hasTraceContinuingInOrientation(label, orientation) {
7532
+ const direction = dir(orientation);
7533
+ return Object.values(this.traceMap).some((trace) => {
7534
+ if (trace.globalConnNetId !== label.globalConnNetId) return false;
7535
+ const path = trace.tracePath;
7536
+ return path.some((point, pointIndex) => {
7537
+ if (Math.abs(point.x - label.anchorPoint.x) > EPS9 || Math.abs(point.y - label.anchorPoint.y) > EPS9) {
7538
+ return false;
7539
+ }
7540
+ return [path[pointIndex - 1], path[pointIndex + 1]].some(
7541
+ (neighbor) => neighbor && Math.abs(neighbor.x - point.x) <= EPS9 && (neighbor.y - point.y) * direction.y > EPS9
7542
+ );
7543
+ });
7544
+ });
7545
+ }
7362
7546
  findValidRotatedCandidate(label, labelIndex, orientations) {
7363
7547
  for (const orientation of orientations) {
7364
7548
  const candidate = this.createCandidate(
@@ -7504,7 +7688,8 @@ var AvailableNetOrientationSolver = class extends BaseSolver {
7504
7688
  baseAnchor,
7505
7689
  maxSearchDistance,
7506
7690
  outwardDistance,
7507
- phase = "shift"
7691
+ phase = "shift",
7692
+ stopOnTraceCollision = true
7508
7693
  } = params;
7509
7694
  for (let distance4 = LABEL_SEARCH_STEP; distance4 <= maxSearchDistance + EPS9; distance4 += LABEL_SEARCH_STEP) {
7510
7695
  const anchorPoint = {
@@ -7525,7 +7710,7 @@ var AvailableNetOrientationSolver = class extends BaseSolver {
7525
7710
  result.selected = true;
7526
7711
  return result;
7527
7712
  }
7528
- if (result.status === "trace-collision") break;
7713
+ if (stopOnTraceCollision && result.status === "trace-collision") break;
7529
7714
  }
7530
7715
  return null;
7531
7716
  }
@@ -11,6 +11,7 @@ import {
11
11
  import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
12
12
  import type { InputPin, InputProblem } from "lib/types/InputProblem"
13
13
  import { dir, type FacingDirection } from "lib/utils/dir"
14
+ import { rectIntersectsAnyTextBox } from "lib/utils/textBoxBounds"
14
15
  import { EPS, LABEL_SEARCH_STEP, WICK_CLEARANCE } from "./constants"
15
16
  import {
16
17
  getConnectorTracePath,
@@ -22,8 +23,8 @@ import {
22
23
  rectsOverlap,
23
24
  simplifyOrthogonalPath,
24
25
  traceCrossesBoundsInterior,
25
- tracePathIntersectsBounds,
26
26
  tracePathCrossesAnyBounds,
27
+ tracePathIntersectsBounds,
27
28
  } from "./geometry"
28
29
  import { getPinMap, getTracePins, toNetLabelPlacementPatch } from "./traces"
29
30
  import type {
@@ -36,7 +37,6 @@ import type {
36
37
  EvaluatedCandidate,
37
38
  } from "./types"
38
39
  import { visualizeAvailableNetOrientationSolver } from "./visualize"
39
- import { rectIntersectsAnyTextBox } from "lib/utils/textBoxBounds"
40
40
 
41
41
  const LABEL_TRACE_CLEARANCE = 0.1
42
42
 
@@ -182,6 +182,34 @@ export class AvailableNetOrientationSolver extends BaseSolver {
182
182
 
183
183
  private findCorrectedCandidate(label: NetLabelPlacement, labelIndex: number) {
184
184
  const orientations = this.getAvailableOrientations(label)
185
+ const requiredOrientation = orientations[0]!
186
+ const isTwoPinNet = this.inputProblem.netConnections.some(
187
+ (connection) =>
188
+ connection.netId === label.netId && connection.pinIds.length === 2,
189
+ )
190
+ if (
191
+ isTwoPinNet &&
192
+ orientations.length === 1 &&
193
+ isYOrientation(requiredOrientation) &&
194
+ this.hasTraceContinuingInOrientation(label, requiredOrientation)
195
+ ) {
196
+ // Keep two-pin vertical net labels aligned with the existing trace.
197
+ const alignedCandidate = this.findValidCandidateInShiftColumn({
198
+ label,
199
+ labelIndex,
200
+ orientation: requiredOrientation,
201
+ direction: dir(requiredOrientation),
202
+ baseAnchor: this.getWickOffsetAnchor(
203
+ label.anchorPoint,
204
+ requiredOrientation,
205
+ ),
206
+ maxSearchDistance: this.maxSearchDistance,
207
+ outwardDistance: 0,
208
+ stopOnTraceCollision: false,
209
+ })
210
+ if (alignedCandidate) return alignedCandidate
211
+ }
212
+
185
213
  const rotatedCandidate = this.findValidRotatedCandidate(
186
214
  label,
187
215
  labelIndex,
@@ -210,6 +238,31 @@ export class AvailableNetOrientationSolver extends BaseSolver {
210
238
  )
211
239
  }
212
240
 
241
+ private hasTraceContinuingInOrientation(
242
+ label: NetLabelPlacement,
243
+ orientation: "y+" | "y-",
244
+ ) {
245
+ const direction = dir(orientation)
246
+ return Object.values(this.traceMap).some((trace) => {
247
+ if (trace.globalConnNetId !== label.globalConnNetId) return false
248
+ const path = trace.tracePath
249
+ return path.some((point, pointIndex) => {
250
+ if (
251
+ Math.abs(point.x - label.anchorPoint.x) > EPS ||
252
+ Math.abs(point.y - label.anchorPoint.y) > EPS
253
+ ) {
254
+ return false
255
+ }
256
+ return [path[pointIndex - 1], path[pointIndex + 1]].some(
257
+ (neighbor) =>
258
+ neighbor &&
259
+ Math.abs(neighbor.x - point.x) <= EPS &&
260
+ (neighbor.y - point.y) * direction.y > EPS,
261
+ )
262
+ })
263
+ })
264
+ }
265
+
213
266
  private findValidRotatedCandidate(
214
267
  label: NetLabelPlacement,
215
268
  labelIndex: number,
@@ -401,6 +454,7 @@ export class AvailableNetOrientationSolver extends BaseSolver {
401
454
  maxSearchDistance: number
402
455
  outwardDistance: number
403
456
  phase?: CandidatePhase
457
+ stopOnTraceCollision?: boolean
404
458
  }) {
405
459
  const {
406
460
  label,
@@ -411,6 +465,7 @@ export class AvailableNetOrientationSolver extends BaseSolver {
411
465
  maxSearchDistance,
412
466
  outwardDistance,
413
467
  phase = "shift",
468
+ stopOnTraceCollision = true,
414
469
  } = params
415
470
 
416
471
  for (
@@ -437,7 +492,7 @@ export class AvailableNetOrientationSolver extends BaseSolver {
437
492
  result.selected = true
438
493
  return result
439
494
  }
440
- if (result.status === "trace-collision") break
495
+ if (stopOnTraceCollision && result.status === "trace-collision") break
441
496
  }
442
497
 
443
498
  return null
@@ -370,6 +370,18 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
370
370
  !isEndpointChipObstacle)
371
371
 
372
372
  if (canGenerateEndpointDetour && (isFirstSegment || isLastSegment)) {
373
+ // A three-point detour replaces an L elbow, so the shortest valid route
374
+ // remains the best choice. A four-point detour preserves the far side of
375
+ // a U elbow; prefer the candidate that keeps its new internal rails out
376
+ // of the pin band, then use length to choose between equivalent routes.
377
+ const compareDetours =
378
+ path.length === 4
379
+ ? (a: Point[], b: Point[]) =>
380
+ this.getPinBandPenalty(a) - this.getPinBandPenalty(b) ||
381
+ this.pathLength(a) - this.pathLength(b)
382
+ : (a: Point[], b: Point[]) =>
383
+ this.pathLength(a) - this.pathLength(b) ||
384
+ this.getPinBandPenalty(a) - this.getPinBandPenalty(b)
373
385
  const detours = generateEndpointCollisionDetours({
374
386
  path,
375
387
  collidingSegmentIndex: segIndex,
@@ -381,11 +393,7 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
381
393
  this.visited.add(key)
382
394
  return true
383
395
  })
384
- .sort(
385
- (a, b) =>
386
- this.pathLength(a) - this.pathLength(b) ||
387
- this.getPinBandPenalty(a) - this.getPinBandPenalty(b),
388
- )
396
+ .sort(compareDetours)
389
397
 
390
398
  for (const detour of detours) {
391
399
  const nextCollisionRects = new Set(collisionRects)