@tscircuit/schematic-trace-solver 0.0.120 → 0.0.122

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 (40) hide show
  1. package/dist/index.d.ts +10 -3
  2. package/dist/index.js +350 -78
  3. package/lib/solvers/RailNetLabelCornerPlacementSolver/RailNetLabelCornerPlacementSolver.ts +223 -21
  4. package/lib/solvers/RailNetLabelCornerPlacementSolver/types.ts +3 -3
  5. package/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +34 -9
  6. package/lib/solvers/TraceCleanupSolver/sameNetRailAlignment/evaluateRailGroup.ts +79 -59
  7. package/lib/solvers/TraceCleanupSolver/sameNetRailAlignment/getRailAlignmentFallbackCoordinates.ts +96 -0
  8. package/lib/utils/doesPathCoincideWithTraces.ts +71 -1
  9. package/package.json +1 -1
  10. package/site/bug-reports/bug-report-20260804T095800Z.page.tsx +4 -0
  11. package/tests/bug-reports/bug-report-20260706T213649Z/__snapshots__/bug-report-20260706T213649Z.snap.svg +4 -4
  12. package/tests/bug-reports/bug-report-20260706T220324Z/__snapshots__/bug-report-20260706T220324Z.snap.svg +5 -5
  13. package/tests/bug-reports/bug-report-20260717T042845Z/__snapshots__/bug-report-20260717T042845Z.snap.svg +12 -12
  14. package/tests/bug-reports/bug-report-20260721T221026Z/__snapshots__/bug-report-20260721T221026Z.snap.svg +2 -2
  15. package/tests/bug-reports/bug-report-20260728T144234Z/__snapshots__/bug-report-20260728T144234Z.snap.svg +2 -2
  16. package/tests/bug-reports/bug-report-20260804T095800Z/__snapshots__/bug-report-20260804T095800Z.snap.svg +135 -0
  17. package/tests/bug-reports/bug-report-20260804T095800Z/bug-report-20260804T095800Z.json +634 -0
  18. package/tests/bug-reports/bug-report-20260804T095800Z/bug-report-20260804T095800Z.test.ts +12 -0
  19. package/tests/examples/__snapshots__/example12.snap.svg +2 -2
  20. package/tests/examples/__snapshots__/example13.snap.svg +4 -4
  21. package/tests/examples/__snapshots__/example14.snap.svg +36 -36
  22. package/tests/examples/__snapshots__/example31.snap.svg +6 -6
  23. package/tests/examples/__snapshots__/example43.snap.svg +2 -2
  24. package/tests/examples/__snapshots__/example44.snap.svg +2 -2
  25. package/tests/repros/__snapshots__/board-15984-same-net-junction.snap.svg +2 -2
  26. package/tests/repros/__snapshots__/bugreport-001-gnd-overlap.snap.svg +26 -26
  27. package/tests/repros/__snapshots__/netlabel-connector-through-rail-label.snap.svg +2 -2
  28. package/tests/repros/__snapshots__/repro-atmega328p-fault-pullup.snap.svg +2 -2
  29. package/tests/repros/__snapshots__/repro-atmega328p-missing-gnd-netlabel.snap.svg +2 -2
  30. package/tests/repros/__snapshots__/repro-bq25895-cross-net-trace-overlap.snap.svg +101 -0
  31. package/tests/repros/__snapshots__/repro-missing-trace-netlabel.snap.svg +18 -18
  32. package/tests/repros/__snapshots__/repro-netlabel-overlap-trace.snap.svg +3 -3
  33. package/tests/repros/__snapshots__/repro-nrf52810-clock-routing.snap.svg +82 -0
  34. package/tests/repros/assets/repro-bq25895-cross-net-trace-overlap.input.json +498 -0
  35. package/tests/repros/assets/repro-nrf52810-clock-routing.input.json +234 -0
  36. package/tests/repros/repro-bq25895-cross-net-trace-overlap.test.ts +88 -0
  37. package/tests/repros/repro-missing-trace-netlabel.test.ts +12 -0
  38. package/tests/repros/repro-netlabel-overlap-trace.test.ts +38 -0
  39. package/tests/repros/repro-nrf52810-clock-routing.test.ts +28 -0
  40. package/tests/solvers/TraceCleanupSolver/alignSameNetRails-trace-clearance.test.ts +68 -0
@@ -1,13 +1,19 @@
1
1
  import type { GraphicsObject } from "graphics-debug"
2
+ import type { Point } from "@tscircuit/math-utils"
2
3
  import { traceCrossesBoundsInterior } from "lib/solvers/AvailableNetOrientationSolver/geometry"
3
4
  import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
5
+ import { moveAttachedLabelsToReroutedTrace } from "lib/solvers/Example28Solver/labelMovement"
4
6
  import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
5
7
  import {
6
8
  getCenterFromAnchor,
7
9
  getRectBounds,
8
10
  } from "lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/geometry"
9
11
  import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
12
+ import { isPathCollidingWithObstacles } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions"
13
+ import { getObstacleRects } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect"
14
+ import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath"
10
15
  import type { InputProblem } from "lib/types/InputProblem"
16
+ import { doesPathOverlapTraceStrokes } from "lib/utils/doesPathCoincideWithTraces"
11
17
  import {
12
18
  EPS,
13
19
  getDistance,
@@ -95,6 +101,7 @@ export class RailNetLabelCornerPlacementSolver extends BaseSolver {
95
101
 
96
102
  getOutput() {
97
103
  return {
104
+ traces: this.traces,
98
105
  netLabelPlacements: this.outputNetLabelPlacements,
99
106
  }
100
107
  }
@@ -163,7 +170,7 @@ export class RailNetLabelCornerPlacementSolver extends BaseSolver {
163
170
  center,
164
171
  width: label.width,
165
172
  height: label.height,
166
- status: this.getCandidateStatus(bounds, labelIndex),
173
+ status: this.getCandidateStatus(bounds, labelIndex, candidate),
167
174
  selected: false,
168
175
  }
169
176
  }
@@ -171,11 +178,27 @@ export class RailNetLabelCornerPlacementSolver extends BaseSolver {
171
178
  private getCandidateStatus(
172
179
  bounds: Bounds,
173
180
  labelIndex: number,
181
+ candidate: TraceCornerCandidate,
174
182
  ): CornerCandidateStatus {
175
183
  if (this.intersectsAnyChip(bounds)) return "chip-collision"
176
184
  if (rectIntersectsAnyTextBox(bounds, this.inputProblem))
177
185
  return "text-collision"
178
- if (traceCrossesBoundsInterior(bounds, this.traceMap)) {
186
+ const candidateTraceMap = candidate.reroutedTracePath
187
+ ? {
188
+ ...this.traceMap,
189
+ [candidate.traceId]: {
190
+ ...this.traceMap[candidate.traceId]!,
191
+ tracePath: candidate.reroutedTracePath,
192
+ },
193
+ }
194
+ : this.traceMap
195
+ if (traceCrossesBoundsInterior(bounds, candidateTraceMap)) {
196
+ return "trace-collision"
197
+ }
198
+ if (
199
+ candidate.reroutedTracePath &&
200
+ !this.isReroutedTraceClear(candidate, labelIndex)
201
+ ) {
179
202
  return "trace-collision"
180
203
  }
181
204
  if (this.intersectsAnyOtherNetLabel(bounds, labelIndex)) {
@@ -190,6 +213,24 @@ export class RailNetLabelCornerPlacementSolver extends BaseSolver {
190
213
  label: NetLabelPlacement,
191
214
  candidate: EvaluatedCornerCandidate,
192
215
  ) {
216
+ if (candidate.reroutedTracePath) {
217
+ const originalTrace = this.traceMap[candidate.traceId]!
218
+ const reroutedTrace = {
219
+ ...originalTrace,
220
+ tracePath: candidate.reroutedTracePath,
221
+ }
222
+ this.traces = this.traces.map((trace) =>
223
+ trace.mspPairId === candidate.traceId ? reroutedTrace : trace,
224
+ )
225
+ this.traceMap[candidate.traceId] = reroutedTrace
226
+ this.outputNetLabelPlacements = moveAttachedLabelsToReroutedTrace({
227
+ trace: originalTrace,
228
+ originalTracePath: originalTrace.tracePath,
229
+ reroutedTracePath: candidate.reroutedTracePath,
230
+ netLabelPlacements: this.outputNetLabelPlacements,
231
+ })
232
+ }
233
+
193
234
  this.outputNetLabelPlacements[labelIndex] = {
194
235
  ...label,
195
236
  anchorPoint: candidate.anchorPoint,
@@ -225,36 +266,197 @@ export class RailNetLabelCornerPlacementSolver extends BaseSolver {
225
266
  }
226
267
 
227
268
  private getCornerCandidatesForLabel(label: NetLabelPlacement) {
228
- const isVertical = label.orientation === "y+" || label.orientation === "y-"
229
- const candidates: TraceCornerCandidate[] = []
230
- const seenCornerKeys = new Set<string>()
269
+ const anchorAlignedCandidates: TraceCornerCandidate[] = []
270
+ const railAlignedCandidates: TraceCornerCandidate[] = []
271
+ const labelTraces = this.getTraceLinesForLabel(label)
272
+ const allowRailAlignedFallback =
273
+ this.isConfiguredRailLabel(label) &&
274
+ !labelTraces.some((trace) =>
275
+ getTraceCorners(trace.tracePath).some((corner) =>
276
+ this.pointsEqual(corner, label.anchorPoint),
277
+ ),
278
+ )
231
279
 
232
- for (const trace of this.getTraceLinesForLabel(label)) {
280
+ for (const trace of labelTraces) {
233
281
  const path = trace.tracePath
234
282
  const pins = [path[0]!, path[path.length - 1]!]
235
283
  for (const anchorPoint of getTraceCorners(path)) {
236
- const key = `${anchorPoint.x}:${anchorPoint.y}`
237
- if (seenCornerKeys.has(key)) continue
238
- seenCornerKeys.add(key)
239
- const pinAligned = pins.some((pin) => {
240
- if (isVertical) return Math.abs(pin.x - anchorPoint.x) < EPS
241
- return Math.abs(pin.y - anchorPoint.y) < EPS
242
- })
243
- // Only pin-aligned corners are worth snapping a rail label to (a clean
244
- // stub off a pin). Non-aligned corners are no better than where the
245
- // label already is, so they aren't offered as candidates — this keeps
246
- // already-well-placed labels untouched.
284
+ const cornerIndex = path.indexOf(anchorPoint)
285
+ const anchorAlignedToPin = pins.some(
286
+ (pin) => Math.abs(pin.x - anchorPoint.x) <= EPS,
287
+ )
288
+ const pinAligned =
289
+ anchorAlignedToPin ||
290
+ (allowRailAlignedFallback &&
291
+ this.isPinAlignedCorner(path, cornerIndex))
247
292
  if (!pinAligned) continue
248
- candidates.push({
293
+
294
+ const candidates = anchorAlignedToPin
295
+ ? anchorAlignedCandidates
296
+ : railAlignedCandidates
297
+ candidates.push(
298
+ {
299
+ anchorPoint,
300
+ traceId: trace.mspPairId,
301
+ distance: getDistance(anchorPoint, label.anchorPoint),
302
+ pinAligned,
303
+ },
304
+ ...this.getClearanceShiftedCornerCandidates({
305
+ label,
306
+ trace,
307
+ cornerIndex,
308
+ pinAligned,
309
+ }),
310
+ )
311
+ }
312
+ }
313
+
314
+ return [
315
+ ...anchorAlignedCandidates.sort((a, b) => a.distance - b.distance),
316
+ ...railAlignedCandidates.sort((a, b) => a.distance - b.distance),
317
+ ]
318
+ }
319
+
320
+ private isConfiguredRailLabel(label: NetLabelPlacement) {
321
+ if (!label.netId) return false
322
+ return this.inputProblem.availableNetLabelOrientations[label.netId]?.some(
323
+ (orientation) => orientation === "y+" || orientation === "y-",
324
+ )
325
+ }
326
+
327
+ private pointsEqual(a: Point, b: Point) {
328
+ return Math.abs(a.x - b.x) <= EPS && Math.abs(a.y - b.y) <= EPS
329
+ }
330
+
331
+ private getVerticalSegmentPointIndices(path: Point[], cornerIndex: number) {
332
+ const corner = path[cornerIndex]!
333
+ const previousIsVertical =
334
+ Math.abs(path[cornerIndex - 1]!.x - corner.x) <= EPS
335
+ const nextIsVertical = Math.abs(path[cornerIndex + 1]!.x - corner.x) <= EPS
336
+ if (previousIsVertical === nextIsVertical) return null
337
+
338
+ const indices = previousIsVertical
339
+ ? [cornerIndex - 1, cornerIndex]
340
+ : [cornerIndex, cornerIndex + 1]
341
+ return indices
342
+ }
343
+
344
+ private isPinAlignedCorner(path: Point[], cornerIndex: number) {
345
+ const verticalPointIndices = this.getVerticalSegmentPointIndices(
346
+ path,
347
+ cornerIndex,
348
+ )
349
+ if (!verticalPointIndices) return false
350
+
351
+ const [startIndex, endIndex] = verticalPointIndices
352
+ return startIndex! <= 1 || endIndex! >= path.length - 2
353
+ }
354
+
355
+ private getClearanceShiftedCornerCandidates({
356
+ label,
357
+ trace,
358
+ cornerIndex,
359
+ pinAligned,
360
+ }: {
361
+ label: NetLabelPlacement
362
+ trace: SolvedTracePath
363
+ cornerIndex: number
364
+ pinAligned: boolean
365
+ }): TraceCornerCandidate[] {
366
+ if (label.orientation !== "y+" && label.orientation !== "y-") return []
367
+
368
+ const path = trace.tracePath
369
+ const corner = path[cornerIndex]!
370
+ const verticalPointIndices = this.getVerticalSegmentPointIndices(
371
+ path,
372
+ cornerIndex,
373
+ )
374
+ if (
375
+ !verticalPointIndices ||
376
+ verticalPointIndices.includes(0) ||
377
+ verticalPointIndices.includes(path.length - 1)
378
+ ) {
379
+ return []
380
+ }
381
+
382
+ const center = getCenterFromAnchor(
383
+ corner,
384
+ label.orientation,
385
+ label.width,
386
+ label.height,
387
+ )
388
+ const labelBounds = getRectBounds(center, label.width, label.height)
389
+ const collidingChipBounds = this.inputProblem.chips
390
+ .map((chip) => getRectBounds(chip.center, chip.width, chip.height))
391
+ .filter((chipBounds) => rectsOverlap(labelBounds, chipBounds))
392
+ const shiftedCoordinates = new Set<number>()
393
+ for (const chipBounds of collidingChipBounds) {
394
+ shiftedCoordinates.add(chipBounds.minX - label.width / 2)
395
+ shiftedCoordinates.add(chipBounds.maxX + label.width / 2)
396
+ }
397
+
398
+ return [...shiftedCoordinates].flatMap((x) => {
399
+ if (Math.abs(x - corner.x) <= EPS) return []
400
+ const reroutedTracePath = simplifyPath(
401
+ path.map((point, pointIndex) =>
402
+ verticalPointIndices.includes(pointIndex) ? { ...point, x } : point,
403
+ ),
404
+ )
405
+ const anchorPoint = { x, y: corner.y }
406
+ const remainsCorner = getTraceCorners(reroutedTracePath).some((point) =>
407
+ this.pointsEqual(point, anchorPoint),
408
+ )
409
+ if (!remainsCorner) return []
410
+
411
+ return [
412
+ {
249
413
  anchorPoint,
250
414
  traceId: trace.mspPairId,
251
415
  distance: getDistance(anchorPoint, label.anchorPoint),
252
416
  pinAligned,
253
- })
254
- }
417
+ reroutedTracePath,
418
+ },
419
+ ]
420
+ })
421
+ }
422
+
423
+ private isReroutedTraceClear(
424
+ candidate: TraceCornerCandidate,
425
+ labelIndex: number,
426
+ ) {
427
+ const reroutedTracePath = candidate.reroutedTracePath!
428
+ if (
429
+ isPathCollidingWithObstacles(
430
+ reroutedTracePath,
431
+ getObstacleRects(this.inputProblem),
432
+ )
433
+ ) {
434
+ return false
435
+ }
436
+
437
+ const candidateTrace = this.traceMap[candidate.traceId]!
438
+ const otherNetTraces = this.traces.filter(
439
+ (trace) =>
440
+ trace.mspPairId !== candidate.traceId &&
441
+ trace.globalConnNetId !== candidateTrace.globalConnNetId,
442
+ )
443
+ if (doesPathOverlapTraceStrokes(reroutedTracePath, otherNetTraces)) {
444
+ return false
255
445
  }
256
446
 
257
- return candidates.sort((a, b) => a.distance - b.distance)
447
+ const reroutedTraceMap = {
448
+ [candidate.traceId]: {
449
+ ...candidateTrace,
450
+ tracePath: reroutedTracePath,
451
+ },
452
+ }
453
+ return this.outputNetLabelPlacements.every((label, index) => {
454
+ if (index === labelIndex) return true
455
+ return !traceCrossesBoundsInterior(
456
+ getRectBounds(label.center, label.width, label.height),
457
+ reroutedTraceMap,
458
+ )
459
+ })
258
460
  }
259
461
 
260
462
  private getTraceLinesForLabel(label: NetLabelPlacement) {
@@ -21,11 +21,11 @@ export type TraceCornerCandidate = {
21
21
  traceId: string
22
22
  distance: number
23
23
  /**
24
- * True when the corner lines up with one of the trace's pins along the
25
- * label's stub axis (same x for a vertical rail label), so the label reads as
26
- * a clean stub off that pin rather than floating over a mid-trace bend.
24
+ * True when the corner is on a vertical rail that connects directly to one
25
+ * of the trace's endpoint pins, rather than on an arbitrary middle bend.
27
26
  */
28
27
  pinAligned: boolean
28
+ reroutedTracePath?: Point[]
29
29
  }
30
30
 
31
31
  export type CornerCandidateStatus =
@@ -22,6 +22,7 @@ import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePa
22
22
  import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver"
23
23
  import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver"
24
24
  import { Example28Solver } from "../Example28Solver/Example28Solver"
25
+ import { moveAttachedLabelsToReroutedTrace } from "../Example28Solver/labelMovement"
25
26
  import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/AvailableNetOrientationSolver"
26
27
  import { RailNetLabelCornerPlacementSolver } from "../RailNetLabelCornerPlacementSolver/RailNetLabelCornerPlacementSolver"
27
28
  import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver"
@@ -325,9 +326,8 @@ export class SchematicTracePipelineSolver extends BaseSolver {
325
326
  (instance) => [
326
327
  {
327
328
  inputProblem: instance.inputProblem,
328
- traces: Object.values(
329
- instance.postLabelTraceOverlapShiftSolver!.correctedTraceMap,
330
- ),
329
+ traces:
330
+ instance.railNetLabelCornerPlacementSolver!.getOutput().traces,
331
331
  netLabelPlacements:
332
332
  instance.railNetLabelCornerPlacementSolver!
333
333
  .outputNetLabelPlacements,
@@ -340,9 +340,8 @@ export class SchematicTracePipelineSolver extends BaseSolver {
340
340
  (instance) => [
341
341
  {
342
342
  inputProblem: instance.inputProblem,
343
- traces: Object.values(
344
- instance.postLabelTraceOverlapShiftSolver!.correctedTraceMap,
345
- ),
343
+ traces:
344
+ instance.railNetLabelCornerPlacementSolver!.getOutput().traces,
346
345
  netLabelPlacements:
347
346
  instance.traceAnchoredNetLabelOverlapSolver!
348
347
  .outputNetLabelPlacements,
@@ -379,14 +378,40 @@ export class SchematicTracePipelineSolver extends BaseSolver {
379
378
  "netLabelTraceCollisionSolver",
380
379
  NetLabelTraceCollisionSolver,
381
380
  (instance) => {
382
- const previousCollisionOutput =
381
+ const previousOutput =
383
382
  instance.preAlignmentNetLabelTraceCollisionSolver!.getOutput()
383
+ const alignmentOutput = instance.traceCleanupSolver2!.getOutput()
384
+ const previousTraceMap = new Map(
385
+ previousOutput.traces.map((trace) => [trace.mspPairId, trace]),
386
+ )
387
+ let netLabelPlacements = previousOutput.netLabelPlacements
388
+
389
+ for (const trace of alignmentOutput.traces) {
390
+ const previousTrace = previousTraceMap.get(trace.mspPairId)
391
+ if (!previousTrace || previousTrace.tracePath === trace.tracePath) {
392
+ continue
393
+ }
394
+
395
+ const currentLabels = netLabelPlacements
396
+ const movedLabels = moveAttachedLabelsToReroutedTrace({
397
+ trace: previousTrace,
398
+ originalTracePath: previousTrace.tracePath,
399
+ reroutedTracePath: trace.tracePath,
400
+ netLabelPlacements: currentLabels,
401
+ })
402
+ netLabelPlacements = movedLabels.map((movedLabel, labelIndex) => {
403
+ const label = currentLabels[labelIndex]!
404
+ return label.orientation === "y+" || label.orientation === "y-"
405
+ ? movedLabel
406
+ : label
407
+ })
408
+ }
384
409
 
385
410
  return [
386
411
  {
387
412
  inputProblem: instance.inputProblem,
388
- traces: instance.traceCleanupSolver2!.getOutput().traces,
389
- netLabelPlacements: previousCollisionOutput.netLabelPlacements,
413
+ traces: alignmentOutput.traces,
414
+ netLabelPlacements,
390
415
  },
391
416
  ]
392
417
  },
@@ -3,8 +3,12 @@ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/Sche
3
3
  import { isPathCollidingWithObstacles } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions"
4
4
  import type { ObstacleRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect"
5
5
  import { detectTraceLabelOverlap } from "lib/solvers/TraceLabelOverlapAvoidanceSolver/detectTraceLabelOverlap"
6
- import { doesPathCoincideWithTraces } from "lib/utils/doesPathCoincideWithTraces"
6
+ import {
7
+ doesPathCoincideWithTraces,
8
+ doesPathOverlapTraceStrokes,
9
+ } from "lib/utils/doesPathCoincideWithTraces"
7
10
  import { getDistinctCoordinates, pointsEqual } from "./geometry"
11
+ import { getRailAlignmentFallbackCoordinates } from "./getRailAlignmentFallbackCoordinates"
8
12
  import { moveRailSegments } from "./moveRailSegments"
9
13
  import { preservesLabelAnchors } from "./preservesLabelAnchors"
10
14
  import {
@@ -43,7 +47,7 @@ export const evaluateRailGroup = ({
43
47
  groupTraceIds.has(trace.mspPairId),
44
48
  )
45
49
  const baseline = getTraceGeometryMetrics(originalGroupTraces, traces)
46
- const coordinates = getDistinctCoordinates(
50
+ const originalCoordinates = getDistinctCoordinates(
47
51
  group.map((segment) => segment.coordinate),
48
52
  )
49
53
  const otherNetTraces = traces.filter(
@@ -55,70 +59,86 @@ export const evaluateRailGroup = ({
55
59
  !eligibleTraceIds.has(trace.mspPairId),
56
60
  )
57
61
 
58
- let best: AlignmentCandidate | null = null
59
- for (const coordinate of coordinates) {
60
- const candidateMap = new Map<string, SolvedTracePath>()
62
+ const evaluateCoordinates = (coordinates: number[]) => {
63
+ let best: AlignmentCandidate | null = null
64
+ for (const coordinate of coordinates) {
65
+ const candidateMap = new Map<string, SolvedTracePath>()
61
66
 
62
- for (const trace of originalGroupTraces) {
63
- const candidateTrace = moveRailSegments(
64
- trace,
65
- group.filter((segment) => segment.traceId === trace.mspPairId),
66
- coordinate,
67
- )
68
- candidateMap.set(trace.mspPairId, candidateTrace)
69
- }
67
+ for (const trace of originalGroupTraces) {
68
+ const candidateTrace = moveRailSegments(
69
+ trace,
70
+ group.filter((segment) => segment.traceId === trace.mspPairId),
71
+ coordinate,
72
+ )
73
+ candidateMap.set(trace.mspPairId, candidateTrace)
74
+ }
70
75
 
71
- const candidateTraces = [...candidateMap.values()]
72
- const allCandidateTraces = traces.map(
73
- (trace) => candidateMap.get(trace.mspPairId) ?? trace,
74
- )
75
- const candidatesAreClear = candidateTraces.every(
76
- (candidate) =>
77
- !isPathCollidingWithObstacles(candidate.tracePath, obstacles) &&
78
- detectTraceLabelOverlap({
79
- traces: [candidate],
80
- netLabels: netLabelPlacements,
81
- }).length === 0 &&
82
- !doesPathCoincideWithTraces(candidate.tracePath, otherNetTraces) &&
83
- !doesPathCoincideWithTraces(
84
- candidate.tracePath,
85
- immutableSameNetTraces.filter(
86
- (trace) => trace.mspPairId !== candidate.mspPairId,
76
+ const candidateTraces = [...candidateMap.values()]
77
+ const allCandidateTraces = traces.map(
78
+ (trace) => candidateMap.get(trace.mspPairId) ?? trace,
79
+ )
80
+ const candidatesAreClear = candidateTraces.every(
81
+ (candidate) =>
82
+ !isPathCollidingWithObstacles(candidate.tracePath, obstacles) &&
83
+ detectTraceLabelOverlap({
84
+ traces: [candidate],
85
+ netLabels: netLabelPlacements,
86
+ }).length === 0 &&
87
+ !doesPathOverlapTraceStrokes(candidate.tracePath, otherNetTraces) &&
88
+ !doesPathCoincideWithTraces(
89
+ candidate.tracePath,
90
+ immutableSameNetTraces.filter(
91
+ (trace) => trace.mspPairId !== candidate.mspPairId,
92
+ ),
87
93
  ),
88
- ),
89
- )
90
- if (!candidatesAreClear) continue
91
- if (
92
- !preservesLabelAnchors(netLabelPlacements, traces, allCandidateTraces)
93
- ) {
94
- continue
95
- }
94
+ )
95
+ if (!candidatesAreClear) continue
96
+ if (
97
+ !preservesLabelAnchors(netLabelPlacements, traces, allCandidateTraces)
98
+ ) {
99
+ continue
100
+ }
96
101
 
97
- const metrics = getTraceGeometryMetrics(candidateTraces, allCandidateTraces)
98
- if (metrics.otherNetCrossings > baseline.otherNetCrossings) continue
99
- if (!isReadabilityImprovement(metrics, baseline)) continue
102
+ const metrics = getTraceGeometryMetrics(
103
+ candidateTraces,
104
+ allCandidateTraces,
105
+ )
106
+ if (metrics.otherNetCrossings > baseline.otherNetCrossings) continue
107
+ if (!isReadabilityImprovement(metrics, baseline)) continue
100
108
 
101
- const score: AlignmentScore = {
102
- ...metrics,
103
- displacement: group.reduce(
104
- (sum, segment) => sum + Math.abs(segment.coordinate - coordinate),
105
- 0,
106
- ),
107
- coordinate,
109
+ const score: AlignmentScore = {
110
+ ...metrics,
111
+ displacement: group.reduce(
112
+ (sum, segment) => sum + Math.abs(segment.coordinate - coordinate),
113
+ 0,
114
+ ),
115
+ coordinate,
116
+ }
117
+ const changedTraceIds = candidateTraces
118
+ .filter((candidate) => {
119
+ const original = traces.find(
120
+ (trace) => trace.mspPairId === candidate.mspPairId,
121
+ )!
122
+ return tracePathChanged(original, candidate)
123
+ })
124
+ .map((trace) => trace.mspPairId)
125
+ if (changedTraceIds.length === 0) continue
126
+
127
+ const candidate = { traces: allCandidateTraces, changedTraceIds, score }
128
+ if (!best || scoreIsBetter(candidate.score, best.score)) best = candidate
108
129
  }
109
- const changedTraceIds = candidateTraces
110
- .filter((candidate) => {
111
- const original = traces.find(
112
- (trace) => trace.mspPairId === candidate.mspPairId,
113
- )!
114
- return tracePathChanged(original, candidate)
115
- })
116
- .map((trace) => trace.mspPairId)
117
- if (changedTraceIds.length === 0) continue
118
130
 
119
- const candidate = { traces: allCandidateTraces, changedTraceIds, score }
120
- if (!best || scoreIsBetter(candidate.score, best.score)) best = candidate
131
+ return best
121
132
  }
122
133
 
123
- return best
134
+ const originalCandidate = evaluateCoordinates(originalCoordinates)
135
+ if (originalCandidate) return originalCandidate
136
+
137
+ return evaluateCoordinates(
138
+ getRailAlignmentFallbackCoordinates({
139
+ group,
140
+ originalCoordinates,
141
+ otherNetTraces,
142
+ }),
143
+ )
124
144
  }
@@ -0,0 +1,96 @@
1
+ import { boundsIntersection, type Bounds } from "@tscircuit/math-utils"
2
+ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
3
+ import {
4
+ SCHEMATIC_TRACE_MIN_CENTERLINE_CLEARANCE,
5
+ SCHEMATIC_TRACE_MIN_VISUAL_CENTERLINE_CLEARANCE,
6
+ } from "lib/utils/doesPathCoincideWithTraces"
7
+ import {
8
+ getDistinctCoordinates,
9
+ getRailOrientation,
10
+ RAIL_ALIGNMENT_EPSILON,
11
+ } from "./geometry"
12
+ import type { RailOrientation, RailSegment } from "./types"
13
+
14
+ const getAlongBounds = (
15
+ orientation: RailOrientation,
16
+ minAlong: number,
17
+ maxAlong: number,
18
+ ): Bounds =>
19
+ orientation === "vertical"
20
+ ? { minX: 0, maxX: 0, minY: minAlong, maxY: maxAlong }
21
+ : { minX: minAlong, maxX: maxAlong, minY: 0, maxY: 0 }
22
+
23
+ const hasPositiveAlongOverlap = (
24
+ orientation: RailOrientation,
25
+ first: Bounds,
26
+ second: Bounds,
27
+ ) => {
28
+ const overlap = boundsIntersection(first, second)
29
+ if (!overlap) return false
30
+ const overlapLength =
31
+ orientation === "vertical"
32
+ ? overlap.maxY - overlap.minY
33
+ : overlap.maxX - overlap.minX
34
+ return overlapLength > RAIL_ALIGNMENT_EPSILON
35
+ }
36
+
37
+ /**
38
+ * Derives alternatives only for original alignment coordinates whose rendered
39
+ * stroke touches a parallel segment from another net.
40
+ */
41
+ export const getRailAlignmentFallbackCoordinates = ({
42
+ group,
43
+ originalCoordinates,
44
+ otherNetTraces,
45
+ }: {
46
+ group: RailSegment[]
47
+ originalCoordinates: number[]
48
+ otherNetTraces: SolvedTracePath[]
49
+ }) => {
50
+ const orientation = group[0]!.orientation
51
+ const groupAlongBounds = group.map((segment) =>
52
+ getAlongBounds(orientation, segment.minAlong, segment.maxAlong),
53
+ )
54
+ const fallbackClearance = SCHEMATIC_TRACE_MIN_VISUAL_CENTERLINE_CLEARANCE
55
+ const coordinates: number[] = []
56
+
57
+ for (const trace of otherNetTraces) {
58
+ for (let index = 0; index < trace.tracePath.length - 1; index++) {
59
+ const start = trace.tracePath[index]!
60
+ const end = trace.tracePath[index + 1]!
61
+ if (getRailOrientation(start, end) !== orientation) continue
62
+
63
+ const minAlong =
64
+ orientation === "vertical"
65
+ ? Math.min(start.y, end.y)
66
+ : Math.min(start.x, end.x)
67
+ const maxAlong =
68
+ orientation === "vertical"
69
+ ? Math.max(start.y, end.y)
70
+ : Math.max(start.x, end.x)
71
+ const traceAlongBounds = getAlongBounds(orientation, minAlong, maxAlong)
72
+ if (
73
+ !groupAlongBounds.some((bounds) =>
74
+ hasPositiveAlongOverlap(orientation, bounds, traceAlongBounds),
75
+ )
76
+ ) {
77
+ continue
78
+ }
79
+
80
+ const coordinate = orientation === "vertical" ? start.x : start.y
81
+ const touchesOriginalCandidate = originalCoordinates.some(
82
+ (originalCoordinate) =>
83
+ Math.abs(originalCoordinate - coordinate) <=
84
+ SCHEMATIC_TRACE_MIN_CENTERLINE_CLEARANCE,
85
+ )
86
+ if (!touchesOriginalCandidate) continue
87
+
88
+ coordinates.push(
89
+ coordinate - fallbackClearance,
90
+ coordinate + fallbackClearance,
91
+ )
92
+ }
93
+ }
94
+
95
+ return getDistinctCoordinates(coordinates)
96
+ }