@tscircuit/schematic-trace-solver 0.0.91 → 0.0.93

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 (22) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.js +150 -21
  3. package/lib/solvers/Example28Solver/doesPathRunAlongChipBoundary.ts +18 -0
  4. package/lib/solvers/Example28Solver/reroute.ts +29 -10
  5. package/lib/solvers/Example28Solver/types.ts +7 -0
  6. package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2.ts +35 -23
  7. package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/calculateDirectShortPath.ts +161 -0
  8. package/package.json +1 -1
  9. package/tests/bug-reports/bug-report-20260706T220324Z/__snapshots__/bug-report-20260706T220324Z.snap.svg +4 -6
  10. package/tests/bug-reports/bug-report-20260707T230831Z/__snapshots__/bug-report-20260707T230831Z.snap.svg +46 -52
  11. package/tests/examples/__snapshots__/example03.snap.svg +44 -44
  12. package/tests/examples/__snapshots__/example32.snap.svg +3 -3
  13. package/tests/examples/__snapshots__/example42.snap.svg +1 -1
  14. package/tests/repros/__snapshots__/repro-missing-trace-netlabel.snap.svg +60 -0
  15. package/tests/repros/__snapshots__/repro-netlabel-overlap-trace.snap.svg +60 -0
  16. package/tests/repros/__snapshots__/repro-rectifier-trace-overlap.snap.svg +58 -0
  17. package/tests/repros/assets/repro-missing-trace-netlabel.input.json +155 -0
  18. package/tests/repros/assets/repro-netlabel-overlap-trace.input.json +115 -0
  19. package/tests/repros/assets/repro-rectifier-trace-overlap.input.json +137 -0
  20. package/tests/repros/repro-missing-trace-netlabel.test.ts +14 -0
  21. package/tests/repros/repro-netlabel-overlap-trace.test.ts +14 -0
  22. package/tests/repros/repro-rectifier-trace-overlap.test.ts +14 -0
package/dist/index.d.ts CHANGED
@@ -194,7 +194,6 @@ declare class SchematicTraceSingleLineSolver2 extends BaseSolver {
194
194
  private axisOfSegment;
195
195
  private pathLength;
196
196
  private getPinBandPenalty;
197
- private pathCost;
198
197
  private isSegmentOutsidePinBand;
199
198
  _step(): void;
200
199
  visualize(): GraphicsObject;
@@ -650,6 +649,13 @@ type TracePathScore = {
650
649
  labelIntersections: number;
651
650
  labelHugDistance: number;
652
651
  traceIntersections: number;
652
+ /**
653
+ * Total length the path runs along a chip boundary. A soft quality penalty
654
+ * (traces hugging a chip edge look bad) that is only preferred against once
655
+ * the more important factors above are equal, so a boundary-hugging route is
656
+ * still chosen over one that actually overlaps a label or another trace.
657
+ */
658
+ chipBoundaryOverlap: number;
653
659
  pathLength: number;
654
660
  };
655
661
  type RerouteCandidateResult = {
package/dist/index.js CHANGED
@@ -577,7 +577,7 @@ var MspConnectionPairSolver = class extends BaseSolver {
577
577
  import "graphics-debug";
578
578
 
579
579
  // lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2.ts
580
- import { calculateElbow } from "calculate-elbow";
580
+ import { calculateElbow as calculateElbow2 } from "calculate-elbow";
581
581
 
582
582
  // lib/solvers/GuidelinesSolver/getInputChipBounds.ts
583
583
  function getInputChipBounds(chip) {
@@ -846,6 +846,111 @@ function getRectBounds(center, w, h) {
846
846
  };
847
847
  }
848
848
 
849
+ // lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/calculateDirectShortPath.ts
850
+ import { calculateElbow } from "calculate-elbow";
851
+ var MAX_SHORT_TRACE_DISTANCE = 0.15;
852
+ var SHORT_TRACE_OVERSHOOT = MAX_SHORT_TRACE_DISTANCE / 7.5;
853
+ var FALLBACK_ELBOW_MAX_OVERSHOOT = 0.2;
854
+ function segmentDirection(from, to) {
855
+ if (to.x > from.x) return "x+";
856
+ if (to.x < from.x) return "x-";
857
+ if (to.y > from.y) return "y+";
858
+ if (to.y < from.y) return "y-";
859
+ return null;
860
+ }
861
+ function pathMatchesPinDirections({
862
+ path,
863
+ pin1,
864
+ pin2
865
+ }) {
866
+ const firstDirection = segmentDirection(path[0], path[1]);
867
+ const lastDirection = segmentDirection(
868
+ path[path.length - 1],
869
+ path[path.length - 2]
870
+ );
871
+ return firstDirection === pin1._facingDirection && lastDirection === pin2._facingDirection;
872
+ }
873
+ function calculateShortOrthogonalRoute(pin1, pin2) {
874
+ if (pin1.x === pin2.x || pin1.y === pin2.y) return null;
875
+ const firstDir = pin1._facingDirection;
876
+ const lastDir = pin2._facingDirection;
877
+ const start = { x: pin1.x, y: pin1.y };
878
+ const end = { x: pin2.x, y: pin2.y };
879
+ let path = null;
880
+ if (firstDir?.startsWith("y") && lastDir?.startsWith("x")) {
881
+ let yOffset = -SHORT_TRACE_OVERSHOOT;
882
+ if (firstDir === "y+") {
883
+ yOffset = SHORT_TRACE_OVERSHOOT;
884
+ }
885
+ const routeY = start.y + yOffset;
886
+ const routeX = (start.x + end.x) / 2;
887
+ path = [
888
+ start,
889
+ { x: start.x, y: routeY },
890
+ { x: routeX, y: routeY },
891
+ { x: routeX, y: end.y },
892
+ end
893
+ ];
894
+ } else if (firstDir?.startsWith("x") && lastDir?.startsWith("y")) {
895
+ let xOffset = -SHORT_TRACE_OVERSHOOT;
896
+ if (firstDir === "x+") {
897
+ xOffset = SHORT_TRACE_OVERSHOOT;
898
+ }
899
+ const routeX = start.x + xOffset;
900
+ const routeY = (start.y + end.y) / 2;
901
+ path = [
902
+ start,
903
+ { x: routeX, y: start.y },
904
+ { x: routeX, y: routeY },
905
+ { x: end.x, y: routeY },
906
+ end
907
+ ];
908
+ }
909
+ if (!path) return null;
910
+ if (pathMatchesPinDirections({ path, pin1, pin2 })) {
911
+ return path;
912
+ }
913
+ return null;
914
+ }
915
+ function calculateDirectShortPath(pin1, pin2) {
916
+ const routingDistance = Math.abs(pin1.x - pin2.x) + Math.abs(pin1.y - pin2.y);
917
+ if (routingDistance > MAX_SHORT_TRACE_DISTANCE) return null;
918
+ const start = { x: pin1.x, y: pin1.y };
919
+ const end = { x: pin2.x, y: pin2.y };
920
+ const orthogonalRoute = calculateShortOrthogonalRoute(pin1, pin2);
921
+ if (orthogonalRoute) return orthogonalRoute;
922
+ let candidatePaths = [];
923
+ if (pin1.x !== pin2.x && pin1.y !== pin2.y) {
924
+ candidatePaths = [
925
+ [start, { x: pin2.x, y: pin1.y }, end],
926
+ [start, { x: pin1.x, y: pin2.y }, end]
927
+ ];
928
+ } else {
929
+ candidatePaths = [[start, end]];
930
+ }
931
+ for (const path of candidatePaths) {
932
+ if (pathMatchesPinDirections({ path, pin1, pin2 })) return path;
933
+ }
934
+ return calculateElbow(
935
+ {
936
+ x: pin1.x,
937
+ y: pin1.y,
938
+ facingDirection: pin1._facingDirection
939
+ },
940
+ {
941
+ x: pin2.x,
942
+ y: pin2.y,
943
+ facingDirection: pin2._facingDirection
944
+ },
945
+ {
946
+ overshoot: Math.min(
947
+ FALLBACK_ELBOW_MAX_OVERSHOOT,
948
+ Math.max(SHORT_TRACE_OVERSHOOT, routingDistance / 4)
949
+ )
950
+ }
951
+ );
952
+ }
953
+
849
954
  // lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2.ts
850
955
  var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
851
956
  pins;
@@ -885,7 +990,8 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
885
990
  ) : []
886
991
  );
887
992
  const [pin1, pin2] = this.pins;
888
- this.baseElbow = calculateElbow(
993
+ const directShortPath = calculateDirectShortPath(pin1, pin2);
994
+ this.baseElbow = directShortPath ?? calculateElbow2(
889
995
  {
890
996
  x: pin1.x,
891
997
  y: pin1.y,
@@ -898,6 +1004,7 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
898
1004
  },
899
1005
  { overshoot: 0.2 }
900
1006
  );
1007
+ this.solvedTracePath = directShortPath;
901
1008
  this.aabb = aabbFromPoints(
902
1009
  { x: pin1.x, y: pin1.y },
903
1010
  { x: pin2.x, y: pin2.y }
@@ -1005,9 +1112,6 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
1005
1112
  }
1006
1113
  return penalty;
1007
1114
  }
1008
- pathCost(path) {
1009
- return this.pathLength(path) + this.getPinBandPenalty(path);
1010
- }
1011
1115
  isSegmentOutsidePinBand(a, b) {
1012
1116
  if (isHorizontal(a, b)) {
1013
1117
  return a.y <= this.aabb.minY || a.y >= this.aabb.maxY;
@@ -1103,8 +1207,12 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
1103
1207
  this.visited.add(key);
1104
1208
  const nextSet = new Set(collisionRects);
1105
1209
  nextSet.add(rect);
1106
- const len = this.pathCost(newPath);
1107
- newStates.push({ path: newPath, collisionRects: nextSet, len });
1210
+ newStates.push({
1211
+ path: newPath,
1212
+ collisionRects: nextSet,
1213
+ length: this.pathLength(newPath),
1214
+ pinBandPenalty: this.getPinBandPenalty(newPath)
1215
+ });
1108
1216
  };
1109
1217
  for (const coord of candidates) {
1110
1218
  addShiftedCandidate(segIndex, axis, coord);
@@ -1136,7 +1244,9 @@ var SchematicTraceSingleLineSolver2 = class extends BaseSolver {
1136
1244
  addShiftedCandidate(adjacentSegIndex, adjacentAxis, coord);
1137
1245
  }
1138
1246
  }
1139
- newStates.sort((a2, b2) => a2.len - b2.len);
1247
+ newStates.sort(
1248
+ (a2, b2) => a2.length - b2.length || a2.pinBandPenalty - b2.pinBandPenalty
1249
+ );
1140
1250
  for (const st of newStates) {
1141
1251
  this.queue.push({ path: st.path, collisionRects: st.collisionRects });
1142
1252
  }
@@ -5520,6 +5630,19 @@ var doesPathRunAlongChipBoundary = (path, chipObstacles) => {
5520
5630
  }
5521
5631
  return false;
5522
5632
  };
5633
+ var getChipBoundaryOverlap = (path, chipObstacles) => {
5634
+ let total = 0;
5635
+ for (let i = 0; i < path.length - 1; i++) {
5636
+ const start = path[i];
5637
+ const end = path[i + 1];
5638
+ for (const obstacle of chipObstacles) {
5639
+ if (!segmentRunsAlongRectBoundary(start, end, obstacle)) continue;
5640
+ const overlap = getSegmentOverlapWithRectSpan(start, end, obstacle);
5641
+ if (overlap > EPS7) total += overlap;
5642
+ }
5643
+ }
5644
+ return total;
5645
+ };
5523
5646
  var getSegmentOverlapWithRectSpan = (start, end, rect) => {
5524
5647
  const isVertical4 = Math.abs(start.x - end.x) < EPS7;
5525
5648
  if (isVertical4) {
@@ -5551,7 +5674,8 @@ var findBestReroutePath = ({
5551
5674
  obstacleLabel,
5552
5675
  outputTraces,
5553
5676
  outputNetLabelPlacements,
5554
- candidateResults
5677
+ candidateResults,
5678
+ chipObstacles
5555
5679
  });
5556
5680
  markSelectedCandidate(candidateResults, bestPath);
5557
5681
  return { bestPath, candidateResults };
@@ -5638,14 +5762,6 @@ var createCandidateResult = ({
5638
5762
  selected: false
5639
5763
  };
5640
5764
  }
5641
- if (doesPathRunAlongChipBoundary(path, chipObstacles)) {
5642
- return {
5643
- path,
5644
- status: "chip-collision",
5645
- usesHorizontalSegmentPush,
5646
- selected: false
5647
- };
5648
- }
5649
5765
  return {
5650
5766
  path,
5651
5767
  score: scoreTracePath({
@@ -5653,7 +5769,8 @@ var createCandidateResult = ({
5653
5769
  tracePath: path,
5654
5770
  obstacleLabel,
5655
5771
  outputTraces,
5656
- outputNetLabelPlacements
5772
+ outputNetLabelPlacements,
5773
+ chipObstacles
5657
5774
  }),
5658
5775
  status: "valid",
5659
5776
  usesHorizontalSegmentPush,
@@ -5743,7 +5860,8 @@ var selectBestReroutePath = ({
5743
5860
  obstacleLabel,
5744
5861
  outputTraces,
5745
5862
  outputNetLabelPlacements,
5746
- candidateResults
5863
+ candidateResults,
5864
+ chipObstacles
5747
5865
  }) => {
5748
5866
  for (const candidate of candidateResults) {
5749
5867
  if (!candidate.usesHorizontalSegmentPush) continue;
@@ -5757,7 +5875,8 @@ var selectBestReroutePath = ({
5757
5875
  tracePath: trace.tracePath,
5758
5876
  obstacleLabel,
5759
5877
  outputTraces,
5760
- outputNetLabelPlacements
5878
+ outputNetLabelPlacements,
5879
+ chipObstacles
5761
5880
  });
5762
5881
  for (const candidate of candidateResults) {
5763
5882
  if (candidate.status !== "valid" || !candidate.score) continue;
@@ -5843,7 +5962,8 @@ var scoreTracePath = ({
5843
5962
  tracePath,
5844
5963
  obstacleLabel,
5845
5964
  outputTraces,
5846
- outputNetLabelPlacements
5965
+ outputNetLabelPlacements,
5966
+ chipObstacles
5847
5967
  }) => {
5848
5968
  const candidateTrace = { ...trace, tracePath };
5849
5969
  return {
@@ -5853,6 +5973,7 @@ var scoreTracePath = ({
5853
5973
  }).length,
5854
5974
  labelHugDistance: getLabelHugDistance(tracePath, obstacleLabel),
5855
5975
  traceIntersections: countTraceIntersections(candidateTrace, outputTraces),
5976
+ chipBoundaryOverlap: getChipBoundaryOverlap(tracePath, chipObstacles),
5856
5977
  pathLength: getPathLength(tracePath)
5857
5978
  };
5858
5979
  };
@@ -5865,6 +5986,14 @@ var countTraceIntersections = (trace, outputTraces) => {
5865
5986
  return count;
5866
5987
  };
5867
5988
  var isBetterScore = (score, bestScore) => {
5989
+ const scoreHasLabelOverlap = score.labelIntersections > 0;
5990
+ const bestHasLabelOverlap = bestScore.labelIntersections > 0;
5991
+ if (scoreHasLabelOverlap !== bestHasLabelOverlap) {
5992
+ return !scoreHasLabelOverlap;
5993
+ }
5994
+ if (score.chipBoundaryOverlap !== bestScore.chipBoundaryOverlap) {
5995
+ return score.chipBoundaryOverlap < bestScore.chipBoundaryOverlap;
5996
+ }
5868
5997
  if (score.labelIntersections !== bestScore.labelIntersections) {
5869
5998
  return score.labelIntersections < bestScore.labelIntersections;
5870
5999
  }
@@ -20,6 +20,24 @@ export const doesPathRunAlongChipBoundary = (
20
20
  return false
21
21
  }
22
22
 
23
+ /** Total length of the path that runs along (overlapping) a chip boundary. */
24
+ export const getChipBoundaryOverlap = (
25
+ path: Point[],
26
+ chipObstacles: ChipObstacle[],
27
+ ) => {
28
+ let total = 0
29
+ for (let i = 0; i < path.length - 1; i++) {
30
+ const start = path[i]!
31
+ const end = path[i + 1]!
32
+ for (const obstacle of chipObstacles) {
33
+ if (!segmentRunsAlongRectBoundary(start, end, obstacle)) continue
34
+ const overlap = getSegmentOverlapWithRectSpan(start, end, obstacle)
35
+ if (overlap > EPS) total += overlap
36
+ }
37
+ }
38
+ return total
39
+ }
40
+
23
41
  const getSegmentOverlapWithRectSpan = (
24
42
  start: Point,
25
43
  end: Point,
@@ -14,7 +14,7 @@ import {
14
14
  getPathLength,
15
15
  isPathCollidingWithChipInterior,
16
16
  } from "./geometry"
17
- import { doesPathRunAlongChipBoundary } from "./doesPathRunAlongChipBoundary"
17
+ import { getChipBoundaryOverlap } from "./doesPathRunAlongChipBoundary"
18
18
  import type {
19
19
  ChipObstacle,
20
20
  RerouteCandidateResult,
@@ -52,6 +52,7 @@ export const findBestReroutePath = ({
52
52
  outputTraces,
53
53
  outputNetLabelPlacements,
54
54
  candidateResults,
55
+ chipObstacles,
55
56
  })
56
57
 
57
58
  markSelectedCandidate(candidateResults, bestPath)
@@ -166,15 +167,11 @@ const createCandidateResult = ({
166
167
  }
167
168
  }
168
169
 
169
- if (doesPathRunAlongChipBoundary(path, chipObstacles)) {
170
- return {
171
- path,
172
- status: "chip-collision",
173
- usesHorizontalSegmentPush,
174
- selected: false,
175
- }
176
- }
177
-
170
+ // Note: a path that merely runs along a chip boundary is NOT rejected here.
171
+ // It is a valid route, just a lower-quality one — scoreTracePath penalizes it
172
+ // via chipBoundaryOverlap so it loses only to routes that are otherwise as
173
+ // good. Hard-rejecting it discarded the best available route when every
174
+ // alternative overlapped a label or another trace.
178
175
  return {
179
176
  path,
180
177
  score: scoreTracePath({
@@ -183,6 +180,7 @@ const createCandidateResult = ({
183
180
  obstacleLabel,
184
181
  outputTraces,
185
182
  outputNetLabelPlacements,
183
+ chipObstacles,
186
184
  }),
187
185
  status: "valid",
188
186
  usesHorizontalSegmentPush,
@@ -293,12 +291,14 @@ const selectBestReroutePath = ({
293
291
  outputTraces,
294
292
  outputNetLabelPlacements,
295
293
  candidateResults,
294
+ chipObstacles,
296
295
  }: {
297
296
  trace: SolvedTracePath
298
297
  obstacleLabel: NetLabelPlacement
299
298
  outputTraces: SolvedTracePath[]
300
299
  outputNetLabelPlacements: NetLabelPlacement[]
301
300
  candidateResults: RerouteCandidateResult[]
301
+ chipObstacles: ChipObstacle[]
302
302
  }) => {
303
303
  for (const candidate of candidateResults) {
304
304
  if (!candidate.usesHorizontalSegmentPush) continue
@@ -314,6 +314,7 @@ const selectBestReroutePath = ({
314
314
  obstacleLabel,
315
315
  outputTraces,
316
316
  outputNetLabelPlacements,
317
+ chipObstacles,
317
318
  })
318
319
 
319
320
  for (const candidate of candidateResults) {
@@ -437,12 +438,14 @@ const scoreTracePath = ({
437
438
  obstacleLabel,
438
439
  outputTraces,
439
440
  outputNetLabelPlacements,
441
+ chipObstacles,
440
442
  }: {
441
443
  trace: SolvedTracePath
442
444
  tracePath: Point[]
443
445
  obstacleLabel: NetLabelPlacement
444
446
  outputTraces: SolvedTracePath[]
445
447
  outputNetLabelPlacements: NetLabelPlacement[]
448
+ chipObstacles: ChipObstacle[]
446
449
  }): TracePathScore => {
447
450
  const candidateTrace = { ...trace, tracePath }
448
451
  return {
@@ -452,6 +455,7 @@ const scoreTracePath = ({
452
455
  }).length,
453
456
  labelHugDistance: getLabelHugDistance(tracePath, obstacleLabel),
454
457
  traceIntersections: countTraceIntersections(candidateTrace, outputTraces),
458
+ chipBoundaryOverlap: getChipBoundaryOverlap(tracePath, chipObstacles),
455
459
  pathLength: getPathLength(tracePath),
456
460
  }
457
461
  }
@@ -469,6 +473,21 @@ const countTraceIntersections = (
469
473
  }
470
474
 
471
475
  const isBetterScore = (score: TracePathScore, bestScore: TracePathScore) => {
476
+ // A route with no label overlap is always preferred over one with any. This
477
+ // is separate from the full overlap count below so that a *clean* route wins
478
+ // even if it hugs a chip boundary, while a route that must overlap a label is
479
+ // still steered away from also hugging a boundary.
480
+ const scoreHasLabelOverlap = score.labelIntersections > 0
481
+ const bestHasLabelOverlap = bestScore.labelIntersections > 0
482
+ if (scoreHasLabelOverlap !== bestHasLabelOverlap) {
483
+ // Reached only when exactly one route overlaps a label; the clean one wins.
484
+ return !scoreHasLabelOverlap
485
+ }
486
+ // Boundary hugging is only accepted as the price of a clean route; once a
487
+ // label overlap is unavoidable, avoid hugging a boundary on top of it.
488
+ if (score.chipBoundaryOverlap !== bestScore.chipBoundaryOverlap) {
489
+ return score.chipBoundaryOverlap < bestScore.chipBoundaryOverlap
490
+ }
472
491
  if (score.labelIntersections !== bestScore.labelIntersections) {
473
492
  return score.labelIntersections < bestScore.labelIntersections
474
493
  }
@@ -15,6 +15,13 @@ export type TracePathScore = {
15
15
  labelIntersections: number
16
16
  labelHugDistance: number
17
17
  traceIntersections: number
18
+ /**
19
+ * Total length the path runs along a chip boundary. A soft quality penalty
20
+ * (traces hugging a chip edge look bad) that is only preferred against once
21
+ * the more important factors above are equal, so a boundary-hugging route is
22
+ * still chosen over one that actually overlaps a label or another trace.
23
+ */
24
+ chipBoundaryOverlap: number
18
25
  pathLength: number
19
26
  }
20
27
 
@@ -18,6 +18,7 @@ import { pathKey, shiftSegmentOrth } from "./pathOps"
18
18
  import type { FacingDirection } from "lib/utils/dir"
19
19
  import { getDimsForOrientation } from "lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/geometry"
20
20
  import type { RectPadding } from "lib/utils/textBoxBounds"
21
+ import { calculateDirectShortPath } from "./calculateDirectShortPath"
21
22
 
22
23
  type PathKey = string
23
24
 
@@ -80,21 +81,26 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
80
81
  : [],
81
82
  )
82
83
 
83
- // Build initial elbow path
84
84
  const [pin1, pin2] = this.pins
85
- this.baseElbow = calculateElbow(
86
- {
87
- x: pin1.x,
88
- y: pin1.y,
89
- facingDirection: pin1._facingDirection!,
90
- },
91
- {
92
- x: pin2.x,
93
- y: pin2.y,
94
- facingDirection: pin2._facingDirection!,
95
- },
96
- { overshoot: 0.2 },
97
- )
85
+ const directShortPath = calculateDirectShortPath(pin1, pin2)
86
+
87
+ // Build initial elbow path
88
+ this.baseElbow =
89
+ directShortPath ??
90
+ calculateElbow(
91
+ {
92
+ x: pin1.x,
93
+ y: pin1.y,
94
+ facingDirection: pin1._facingDirection!,
95
+ },
96
+ {
97
+ x: pin2.x,
98
+ y: pin2.y,
99
+ facingDirection: pin2._facingDirection!,
100
+ },
101
+ { overshoot: 0.2 },
102
+ )
103
+ this.solvedTracePath = directShortPath
98
104
 
99
105
  // Bounds defined by PA and PB
100
106
  this.aabb = aabbFromPoints(
@@ -233,10 +239,6 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
233
239
  return penalty
234
240
  }
235
241
 
236
- private pathCost(path: Point[]): number {
237
- return this.pathLength(path) + this.getPinBandPenalty(path)
238
- }
239
-
240
242
  private isSegmentOutsidePinBand(a: Point, b: Point): boolean {
241
243
  if (isHorizontal(a, b)) {
242
244
  return a.y <= this.aabb.minY || a.y >= this.aabb.maxY
@@ -356,11 +358,15 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
356
358
  candidates.push(...mids)
357
359
  }
358
360
 
359
- // Generate new shifted paths, order by total path length (shorter first)
361
+ // Generate new shifted paths. Order by path length first, then prefer paths
362
+ // that stay out of the pin band. The pin-band preference is a tiebreaker
363
+ // only: it must never make the solver pick a longer route (a flat additive
364
+ // penalty would, discarding a shorter valid route in favor of a detour).
360
365
  const newStates: Array<{
361
366
  path: Point[]
362
367
  collisionRects: Set<ObstacleRect>
363
- len: number
368
+ length: number
369
+ pinBandPenalty: number
364
370
  }> = []
365
371
 
366
372
  const addShiftedCandidate = (
@@ -380,8 +386,12 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
380
386
  this.visited.add(key)
381
387
  const nextSet = new Set(collisionRects)
382
388
  nextSet.add(rect)
383
- const len = this.pathCost(newPath)
384
- newStates.push({ path: newPath, collisionRects: nextSet, len })
389
+ newStates.push({
390
+ path: newPath,
391
+ collisionRects: nextSet,
392
+ length: this.pathLength(newPath),
393
+ pinBandPenalty: this.getPinBandPenalty(newPath),
394
+ })
385
395
  }
386
396
 
387
397
  for (const coord of candidates) {
@@ -424,7 +434,9 @@ export class SchematicTraceSingleLineSolver2 extends BaseSolver {
424
434
  }
425
435
  }
426
436
 
427
- newStates.sort((a, b) => a.len - b.len)
437
+ newStates.sort(
438
+ (a, b) => a.length - b.length || a.pinBandPenalty - b.pinBandPenalty,
439
+ )
428
440
  for (const st of newStates) {
429
441
  this.queue.push({ path: st.path, collisionRects: st.collisionRects })
430
442
  }