@tscircuit/schematic-trace-solver 0.0.154 → 0.0.155

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.
@@ -653,10 +653,8 @@ export class AvailableNetOrientationSolver extends BaseSolver {
653
653
  labelIndex: number,
654
654
  ) {
655
655
  const direction = dir(orientation)
656
- const candidatePoints = this.getTraceAnchorCandidatePoints(
657
- label,
658
- orientation,
659
- ).sort((a, b) => {
656
+ const { points } = this.getTraceAnchorCandidates(label, orientation)
657
+ const candidatePoints = points.sort((a, b) => {
660
658
  const aAlongDirection = a.x * direction.x + a.y * direction.y
661
659
  const bAlongDirection = b.x * direction.x + b.y * direction.y
662
660
  return bAlongDirection - aAlongDirection
@@ -687,20 +685,29 @@ export class AvailableNetOrientationSolver extends BaseSolver {
687
685
  labelIndex: number,
688
686
  ) {
689
687
  const direction = dir(orientation)
690
- const candidatePoints = this.getTraceAnchorCandidatePoints(
691
- label,
692
- orientation,
693
- ).sort((a, b) => {
688
+ const preservedColumnAnchor = this.getSearchStartAnchor(label, orientation)
689
+ const { points, preferNearestBendOnOutwardRow } =
690
+ this.getTraceAnchorCandidates(label, orientation)
691
+ const candidatePoints = points.sort((a, b) => {
694
692
  const aAlongDirection = a.x * direction.x + a.y * direction.y
695
693
  const bAlongDirection = b.x * direction.x + b.y * direction.y
696
- return bAlongDirection - aAlongDirection
694
+ if (!preferNearestBendOnOutwardRow) {
695
+ return bAlongDirection - aAlongDirection
696
+ }
697
+
698
+ const aPerpendicularDistance = isYOrientation(orientation)
699
+ ? Math.abs(a.x - preservedColumnAnchor.x)
700
+ : Math.abs(a.y - preservedColumnAnchor.y)
701
+ const bPerpendicularDistance = isYOrientation(orientation)
702
+ ? Math.abs(b.x - preservedColumnAnchor.x)
703
+ : Math.abs(b.y - preservedColumnAnchor.y)
704
+ return (
705
+ bAlongDirection - aAlongDirection ||
706
+ aPerpendicularDistance - bPerpendicularDistance
707
+ )
697
708
  })
698
709
 
699
710
  for (const connectorSource of candidatePoints) {
700
- const preservedColumnAnchor = this.getSearchStartAnchor(
701
- label,
702
- orientation,
703
- )
704
711
  const preservedColumnCandidate = this.createCandidate(
705
712
  label,
706
713
  {
@@ -762,13 +769,14 @@ export class AvailableNetOrientationSolver extends BaseSolver {
762
769
  return null
763
770
  }
764
771
 
765
- private getTraceAnchorCandidatePoints(
772
+ private getTraceAnchorCandidates(
766
773
  label: NetLabelPlacement,
767
774
  orientation: FacingDirection,
768
775
  ) {
769
776
  const seen = new Set<string>()
770
777
  const points: Point[] = []
771
- const connectedTraceIds = new Set(label.mspConnectionPairIds ?? [])
778
+ const directHostTraceIds = new Set(label.mspConnectionPairIds ?? [])
779
+ const connectedTraceIds = new Set(directHostTraceIds)
772
780
 
773
781
  if (isYOrientation(orientation)) {
774
782
  const connectedPinIds = new Set<string>()
@@ -818,7 +826,43 @@ export class AvailableNetOrientationSolver extends BaseSolver {
818
826
  }
819
827
  }
820
828
 
821
- return points
829
+ const direction = dir(orientation)
830
+ const furthestAlong = Math.max(
831
+ ...points.map((point) => point.x * direction.x + point.y * direction.y),
832
+ )
833
+ const furthestRowPoints = points.filter(
834
+ (point) =>
835
+ Math.abs(
836
+ point.x * direction.x + point.y * direction.y - furthestAlong,
837
+ ) <= EPS,
838
+ )
839
+ const directHostPinIds = new Set(
840
+ [...directHostTraceIds].flatMap(
841
+ (traceId) => this.traceMap[traceId]?.pinIds ?? [],
842
+ ),
843
+ )
844
+ const adjacentTraces = [...connectedTraceIds].flatMap((traceId) => {
845
+ if (directHostTraceIds.has(traceId)) return []
846
+ const trace = this.traceMap[traceId]
847
+ if (!trace?.pinIds.some((pinId) => directHostPinIds.has(pinId))) return []
848
+ return [trace]
849
+ })
850
+
851
+ return {
852
+ points,
853
+ // Prefer the nearest bend when the outward row is on a trace directly
854
+ // adjacent to the label's host. Deeper transitive chains keep their
855
+ // stable trace ordering.
856
+ preferNearestBendOnOutwardRow: adjacentTraces.some((trace) =>
857
+ furthestRowPoints.some((point) =>
858
+ trace.tracePath.some(
859
+ (tracePoint) =>
860
+ Math.abs(tracePoint.x - point.x) <= EPS &&
861
+ Math.abs(tracePoint.y - point.y) <= EPS,
862
+ ),
863
+ ),
864
+ ),
865
+ }
822
866
  }
823
867
 
824
868
  private sharesVerticalRailWithAny(
@@ -13,6 +13,14 @@ interface AlignSameNetRailsInput {
13
13
  eligibleTraceIds: ReadonlySet<string>
14
14
  }
15
15
 
16
+ const getTraceStateKey = (traces: SolvedTracePath[]) =>
17
+ JSON.stringify(
18
+ traces.map((trace) => ({
19
+ mspPairId: trace.mspPairId,
20
+ tracePath: trace.tracePath,
21
+ })),
22
+ )
23
+
16
24
  export const alignSameNetRails = ({
17
25
  inputProblem,
18
26
  traces,
@@ -24,6 +32,7 @@ export const alignSameNetRails = ({
24
32
  alignedTraceCount: number
25
33
  } => {
26
34
  let outputTraces = [...traces]
35
+ const seenTraceStates = new Set([getTraceStateKey(outputTraces)])
27
36
  const obstacles = getObstacleRects(inputProblem)
28
37
  const alignedTraceIds = new Set<string>()
29
38
  let alignedRailGroupCount = 0
@@ -38,6 +47,7 @@ export const alignSameNetRails = ({
38
47
  eligibleTraceIds,
39
48
  inputProblem,
40
49
  obstacles,
50
+ netLabelPlacements,
41
51
  )
42
52
  let applied: AlignmentCandidate | null = null
43
53
 
@@ -53,9 +63,16 @@ export const alignSameNetRails = ({
53
63
  }
54
64
  if (!applied) break
55
65
 
66
+ const candidateStateKey = getTraceStateKey(applied.traces)
67
+ // A rail can be eligible from both endpoint components. When that creates
68
+ // a cycle, restore the first-seen state instead of making the final layout
69
+ // depend on the maximum-pass parity.
70
+ const repeatsSeenState = seenTraceStates.has(candidateStateKey)
71
+ if (!repeatsSeenState) seenTraceStates.add(candidateStateKey)
56
72
  outputTraces = applied.traces
57
73
  alignedRailGroupCount++
58
74
  for (const traceId of applied.changedTraceIds) alignedTraceIds.add(traceId)
75
+ if (repeatsSeenState) break
59
76
  }
60
77
 
61
78
  return {
@@ -9,6 +9,7 @@ import {
9
9
  } from "lib/utils/doesPathCoincideWithTraces"
10
10
  import { getDistinctCoordinates, pointsEqual } from "./geometry"
11
11
  import { getRailAlignmentFallbackCoordinates } from "./getRailAlignmentFallbackCoordinates"
12
+ import { getFixedLabelCoordinate } from "./getFixedLabelCoordinate"
12
13
  import { moveRailSegments } from "./moveRailSegments"
13
14
  import { preservesLabelAnchors } from "./preservesLabelAnchors"
14
15
  import {
@@ -50,6 +51,11 @@ export const evaluateRailGroup = ({
50
51
  const originalCoordinates = getDistinctCoordinates(
51
52
  group.map((segment) => segment.coordinate),
52
53
  )
54
+ const fixedLabelCoordinate = getFixedLabelCoordinate(
55
+ group,
56
+ netLabelPlacements,
57
+ traces,
58
+ )
53
59
  const otherNetTraces = traces.filter(
54
60
  (trace) => trace.globalConnNetId !== group[0]!.globalConnNetId,
55
61
  )
@@ -59,7 +65,10 @@ export const evaluateRailGroup = ({
59
65
  !eligibleTraceIds.has(trace.mspPairId),
60
66
  )
61
67
 
62
- const evaluateCoordinates = (coordinates: number[]) => {
68
+ const evaluateCoordinates = (
69
+ coordinates: number[],
70
+ options?: { coordinateIsFixedByLabel?: boolean },
71
+ ) => {
63
72
  let best: AlignmentCandidate | null = null
64
73
  for (const coordinate of coordinates) {
65
74
  const candidateMap = new Map<string, SolvedTracePath>()
@@ -104,7 +113,15 @@ export const evaluateRailGroup = ({
104
113
  allCandidateTraces,
105
114
  )
106
115
  if (metrics.otherNetCrossings > baseline.otherNetCrossings) continue
107
- if (!isReadabilityImprovement(metrics, baseline)) continue
116
+ // A fixed label anchor determines the rail coordinate. It may lengthen
117
+ // endpoint legs, but it must still preserve turns and every safety gate.
118
+ if (
119
+ options?.coordinateIsFixedByLabel
120
+ ? metrics.turnCount > baseline.turnCount
121
+ : !isReadabilityImprovement(metrics, baseline)
122
+ ) {
123
+ continue
124
+ }
108
125
 
109
126
  const score: AlignmentScore = {
110
127
  ...metrics,
@@ -124,16 +141,27 @@ export const evaluateRailGroup = ({
124
141
  .map((trace) => trace.mspPairId)
125
142
  if (changedTraceIds.length === 0) continue
126
143
 
127
- const candidate = { traces: allCandidateTraces, changedTraceIds, score }
144
+ const candidate = {
145
+ traces: allCandidateTraces,
146
+ changedTraceIds,
147
+ score,
148
+ }
128
149
  if (!best || scoreIsBetter(candidate.score, best.score)) best = candidate
129
150
  }
130
151
 
131
152
  return best
132
153
  }
133
154
 
134
- const originalCandidate = evaluateCoordinates(originalCoordinates)
155
+ const originalCandidate = evaluateCoordinates(
156
+ fixedLabelCoordinate === null
157
+ ? originalCoordinates
158
+ : [fixedLabelCoordinate],
159
+ { coordinateIsFixedByLabel: fixedLabelCoordinate !== null },
160
+ )
135
161
  if (originalCandidate) return originalCandidate
136
162
 
163
+ if (fixedLabelCoordinate !== null) return null
164
+
137
165
  return evaluateCoordinates(
138
166
  getRailAlignmentFallbackCoordinates({
139
167
  group,
@@ -1,3 +1,5 @@
1
+ import { distance } from "@tscircuit/math-utils"
2
+ import { DEFAULT_MAX_MSP_PAIR_DISTANCE } from "lib/solvers/MspConnectionPairSolver/MspConnectionPairSolver"
1
3
  import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
2
4
  import { getPinDirection } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/getPinDirection"
3
5
  import type { InputChip } from "lib/types/InputProblem"
@@ -80,12 +82,22 @@ const railIsOutsideComponent = (
80
82
  }
81
83
  }
82
84
 
83
- /** Associates each movable internal rail with the nearest component endpoint. */
85
+ /**
86
+ * Associates each movable rail with its nearest endpoint. Label-anchored group
87
+ * discovery may opt into equally-near endpoints for local connections.
88
+ */
84
89
  export const getComponentSideRailSegments = (
85
90
  trace: SolvedTracePath,
86
91
  chipMap: Map<string, InputChip>,
92
+ options?: {
93
+ includeTiedEndpointAssociations?: boolean
94
+ maxMspPairDistance?: number
95
+ },
87
96
  ): RailSegment[] => {
88
97
  const segments: RailSegment[] = []
98
+ const isLocalConnection =
99
+ distance(trace.pins[0]!, trace.pins[1]!) <=
100
+ (options?.maxMspPairDistance ?? DEFAULT_MAX_MSP_PAIR_DISTANCE)
89
101
 
90
102
  for (const segment of getMovableRailSegments(trace)) {
91
103
  const associations = trace.pins.flatMap((pin, pinIndex) => {
@@ -111,10 +123,21 @@ export const getComponentSideRailSegments = (
111
123
  })
112
124
 
113
125
  associations.sort((a, b) => a.distanceFromEndpoint - b.distanceFromEndpoint)
114
- const association = associations[0]
115
- if (!association) continue
116
-
117
- segments.push({ ...segment, ...association })
126
+ const minimumDistance = associations[0]?.distanceFromEndpoint
127
+ const nearestAssociationKeys = new Set<string>()
128
+ for (const association of associations) {
129
+ if (
130
+ nearestAssociationKeys.size > 0 &&
131
+ (!options?.includeTiedEndpointAssociations || !isLocalConnection)
132
+ ) {
133
+ break
134
+ }
135
+ if (association.distanceFromEndpoint !== minimumDistance) continue
136
+ const associationKey = `${association.componentId}:${association.componentFacingDirection}`
137
+ if (nearestAssociationKeys.has(associationKey)) continue
138
+ nearestAssociationKeys.add(associationKey)
139
+ segments.push({ ...segment, ...association })
140
+ }
118
141
  }
119
142
 
120
143
  return segments
@@ -0,0 +1,114 @@
1
+ import { distance } from "@tscircuit/math-utils"
2
+ import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
3
+ import { tracePathContainsPoint } from "lib/solvers/RailNetLabelCornerPlacementSolver/geometry"
4
+ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
5
+ import { getDistinctCoordinates, RAIL_ALIGNMENT_EPSILON } from "./geometry"
6
+ import type { RailSegment } from "./types"
7
+
8
+ const getTransitivelyConnectedTraceIds = (
9
+ group: RailSegment[],
10
+ traces: SolvedTracePath[],
11
+ ) => {
12
+ const groupNetId = group[0]!.globalConnNetId
13
+ const sameNetTraces = traces.filter(
14
+ (trace) => trace.globalConnNetId === groupNetId,
15
+ )
16
+ const connectedTraceIds = new Set(group.map((segment) => segment.traceId))
17
+ const connectedPinIds = new Set(
18
+ sameNetTraces
19
+ .filter((trace) => connectedTraceIds.has(trace.mspPairId))
20
+ .flatMap((trace) => trace.pinIds),
21
+ )
22
+
23
+ for (let changed = true; changed; ) {
24
+ changed = false
25
+ for (const trace of sameNetTraces) {
26
+ if (connectedTraceIds.has(trace.mspPairId)) continue
27
+ if (!trace.pinIds.some((pinId) => connectedPinIds.has(pinId))) continue
28
+
29
+ connectedTraceIds.add(trace.mspPairId)
30
+ for (const pinId of trace.pinIds) connectedPinIds.add(pinId)
31
+ changed = true
32
+ }
33
+ }
34
+
35
+ return connectedTraceIds
36
+ }
37
+
38
+ const getLabelsLinkedToRailGroup = (
39
+ group: RailSegment[],
40
+ netLabelPlacements: NetLabelPlacement[],
41
+ traces: SolvedTracePath[],
42
+ ) => {
43
+ const traceIds = getTransitivelyConnectedTraceIds(group, traces)
44
+ return netLabelPlacements.filter((label) =>
45
+ label.mspConnectionPairIds.some((traceId) => traceIds.has(traceId)),
46
+ )
47
+ }
48
+
49
+ export const getFixedLabelCoordinate = (
50
+ group: RailSegment[],
51
+ netLabelPlacements: NetLabelPlacement[],
52
+ traces: SolvedTracePath[],
53
+ ) => {
54
+ const traceMap = new Map(traces.map((trace) => [trace.mspPairId, trace]))
55
+ const groupTraceIds = new Set(group.map((segment) => segment.traceId))
56
+ const groupHasOnlySingleRailTraces = [...groupTraceIds].every(
57
+ (traceId) => traceMap.get(traceId)?.tracePath.length === 4,
58
+ )
59
+ // A fixed label may lengthen the endpoint legs around one movable rail. A
60
+ // trace with more elbows still has to pass the normal readability scoring.
61
+ if (!groupHasOnlySingleRailTraces) return null
62
+
63
+ const orientation = group[0]!.orientation
64
+ const linkedLabels = getLabelsLinkedToRailGroup(
65
+ group,
66
+ netLabelPlacements,
67
+ traces,
68
+ )
69
+ const anchoringLabels = linkedLabels.filter(
70
+ (label) =>
71
+ label.orientation === group[0]!.componentFacingDirection &&
72
+ label.mspConnectionPairIds.some((traceId) => {
73
+ const trace = traceMap.get(traceId)
74
+ return (
75
+ trace && tracePathContainsPoint(trace.tracePath, label.anchorPoint)
76
+ )
77
+ }),
78
+ )
79
+ const coordinates = getDistinctCoordinates(
80
+ anchoringLabels.map((label) =>
81
+ orientation === "vertical" ? label.anchorPoint.x : label.anchorPoint.y,
82
+ ),
83
+ )
84
+ if (coordinates.length !== 1) return null
85
+
86
+ const labelCoordinate = coordinates[0]!
87
+ const coordinate =
88
+ group.find(
89
+ (segment) =>
90
+ Math.abs(segment.coordinate - labelCoordinate) <=
91
+ RAIL_ALIGNMENT_EPSILON,
92
+ )?.coordinate ?? labelCoordinate
93
+ const labelIsAnchoredToRoutedBackbone = anchoringLabels.some((label) =>
94
+ label.mspConnectionPairIds.some(
95
+ (traceId) => (traceMap.get(traceId)?.tracePath.length ?? 0) > 4,
96
+ ),
97
+ )
98
+ // A multi-turn label trace is an intentional routed backbone, so its rail
99
+ // coordinate may propagate through the local component chain. Labels riding
100
+ // a single movable rail remain subject to the per-connection locality gate.
101
+ if (labelIsAnchoredToRoutedBackbone) return coordinate
102
+
103
+ // Rail cleanup is local: a label cannot pull a connection farther than the
104
+ // span of the pins that connection already joins.
105
+ const coordinateIsLocalToEveryTrace = group.every((segment) => {
106
+ const trace = traceMap.get(segment.traceId)
107
+ if (!trace) return false
108
+ return (
109
+ Math.abs(segment.coordinate - coordinate) <=
110
+ distance(trace.pins[0]!, trace.pins[1]!) + RAIL_ALIGNMENT_EPSILON
111
+ )
112
+ })
113
+ return coordinateIsLocalToEveryTrace ? coordinate : null
114
+ }
@@ -3,7 +3,9 @@ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/Sche
3
3
  import { segmentIntersectsRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions"
4
4
  import type { ObstacleRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect"
5
5
  import type { InputProblem } from "lib/types/InputProblem"
6
+ import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
6
7
  import { getComponentSideRailSegments } from "./getComponentSideRailSegments"
8
+ import { getFixedLabelCoordinate } from "./getFixedLabelCoordinate"
7
9
  import { nearlyEqual, rangesTouchOrOverlap } from "./geometry"
8
10
  import type { RailSegment } from "./types"
9
11
 
@@ -66,49 +68,113 @@ export const getRailGroups = (
66
68
  eligibleTraceIds: ReadonlySet<string>,
67
69
  inputProblem: InputProblem,
68
70
  obstacles: ObstacleRect[],
71
+ netLabelPlacements: NetLabelPlacement[],
69
72
  ): RailSegment[][] => {
70
73
  const chipMap = new Map(inputProblem.chips.map((chip) => [chip.chipId, chip]))
71
- const segments = traces
72
- .filter((trace) => eligibleTraceIds.has(trace.mspPairId))
73
- .flatMap((trace) => getComponentSideRailSegments(trace, chipMap))
74
74
  const traceMap = new Map(traces.map((trace) => [trace.mspPairId, trace]))
75
- const visited = new Set<number>()
76
- const groups: RailSegment[][] = []
77
-
78
- for (let startIndex = 0; startIndex < segments.length; startIndex++) {
79
- if (visited.has(startIndex)) continue
80
-
81
- const start = segments[startIndex]!
82
- const queue = [startIndex]
83
- const group: RailSegment[] = []
84
- visited.add(startIndex)
85
-
86
- for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
87
- const current = segments[queue[queueIndex]!]!
88
- group.push(current)
89
-
90
- for (
91
- let candidateIndex = 0;
92
- candidateIndex < segments.length;
93
- candidateIndex++
94
- ) {
95
- if (visited.has(candidateIndex)) continue
96
- const candidate = segments[candidateIndex]!
97
- if (!canJoinRailGroup(start, current, candidate, traceMap, obstacles)) {
98
- continue
99
- }
75
+ const eligibleTraces = traces.filter((trace) =>
76
+ eligibleTraceIds.has(trace.mspPairId),
77
+ )
78
+
79
+ const collectConnectedGroups = (segments: RailSegment[]) => {
80
+ const visited = new Set<number>()
81
+ const connectedGroups: RailSegment[][] = []
82
+
83
+ for (let startIndex = 0; startIndex < segments.length; startIndex++) {
84
+ if (visited.has(startIndex)) continue
85
+
86
+ const start = segments[startIndex]!
87
+ const queue = [startIndex]
88
+ const group: RailSegment[] = []
89
+ visited.add(startIndex)
90
+
91
+ for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
92
+ const current = segments[queue[queueIndex]!]!
93
+ group.push(current)
100
94
 
101
- visited.add(candidateIndex)
102
- queue.push(candidateIndex)
95
+ for (
96
+ let candidateIndex = 0;
97
+ candidateIndex < segments.length;
98
+ candidateIndex++
99
+ ) {
100
+ if (visited.has(candidateIndex)) continue
101
+ const candidate = segments[candidateIndex]!
102
+ if (
103
+ !canJoinRailGroup(start, current, candidate, traceMap, obstacles)
104
+ ) {
105
+ continue
106
+ }
107
+
108
+ visited.add(candidateIndex)
109
+ queue.push(candidateIndex)
110
+ }
103
111
  }
112
+
113
+ connectedGroups.push(group)
104
114
  }
105
115
 
116
+ return connectedGroups
117
+ }
118
+
119
+ const groupKey = (group: RailSegment[]) =>
120
+ [
121
+ group[0]!.componentId,
122
+ group[0]!.componentFacingDirection,
123
+ group[0]!.orientation,
124
+ ...group
125
+ .map((segment) => `${segment.traceId}:${segment.segmentIndex}`)
126
+ .sort(),
127
+ ].join("|")
128
+
129
+ const selectedGroups: RailSegment[][] = []
130
+ const selectedGroupKeys = new Set<string>()
131
+ const addEligibleGroup = (
132
+ group: RailSegment[],
133
+ options?: { requireFixedLabel?: boolean },
134
+ ) => {
106
135
  const traceCount = new Set(group.map((segment) => segment.traceId)).size
136
+ if (traceCount < 2) return
137
+
138
+ const fixedLabelCoordinate = getFixedLabelCoordinate(
139
+ group,
140
+ netLabelPlacements,
141
+ traces,
142
+ )
143
+ if (options?.requireFixedLabel && fixedLabelCoordinate === null) return
144
+
107
145
  const hasDifferentCoordinates = group.some(
108
146
  (segment) => !nearlyEqual(segment.coordinate, group[0]!.coordinate),
109
147
  )
110
- if (traceCount >= 2 && hasDifferentCoordinates) groups.push(group)
148
+ const hasDifferentFixedLabelCoordinate =
149
+ fixedLabelCoordinate !== null &&
150
+ !nearlyEqual(fixedLabelCoordinate, group[0]!.coordinate)
151
+ if (!hasDifferentCoordinates && !hasDifferentFixedLabelCoordinate) return
152
+
153
+ const key = groupKey(group)
154
+ if (selectedGroupKeys.has(key)) return
155
+ selectedGroupKeys.add(key)
156
+ selectedGroups.push(group)
157
+ }
158
+
159
+ const primarySegments = eligibleTraces.flatMap((trace) =>
160
+ getComponentSideRailSegments(trace, chipMap),
161
+ )
162
+ // Preserve the original nearest-endpoint grouping and its ordering.
163
+ for (const group of collectConnectedGroups(primarySegments)) {
164
+ addEligibleGroup(group)
165
+ }
166
+
167
+ // Equal-distance endpoint associations can bridge a component chain, but
168
+ // only a fixed label is allowed to opt that broader group into alignment.
169
+ const tiedEndpointSegments = eligibleTraces.flatMap((trace) =>
170
+ getComponentSideRailSegments(trace, chipMap, {
171
+ includeTiedEndpointAssociations: true,
172
+ maxMspPairDistance: inputProblem.maxMspPairDistance,
173
+ }),
174
+ )
175
+ for (const group of collectConnectedGroups(tiedEndpointSegments)) {
176
+ addEligibleGroup(group, { requireFixedLabel: true })
111
177
  }
112
178
 
113
- return groups
179
+ return selectedGroups
114
180
  }
@@ -1,5 +1,6 @@
1
1
  import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
2
2
  import { simplifyPath } from "../simplifyPath"
3
+ import { nearlyEqual } from "./geometry"
3
4
  import type { RailSegment } from "./types"
4
5
 
5
6
  export const moveRailSegments = (
@@ -9,6 +10,7 @@ export const moveRailSegments = (
9
10
  ): SolvedTracePath => {
10
11
  const pointsToMove = new Set<number>()
11
12
  for (const segment of segments) {
13
+ if (nearlyEqual(segment.coordinate, coordinate)) continue
12
14
  pointsToMove.add(segment.segmentIndex)
13
15
  pointsToMove.add(segment.segmentIndex + 1)
14
16
  }
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.154",
8
+ "version": "0.0.155",
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "start": "cosmos",