@tscircuit/schematic-trace-solver 0.0.187 → 0.0.189

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 (60) hide show
  1. package/dist/index.d.ts +42 -23
  2. package/dist/index.js +1053 -316
  3. package/lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver.ts +612 -230
  4. package/lib/solvers/InlineNetLabelSolver/alignPortOnlyInlineNetLabelStubs.ts +2 -6
  5. package/lib/solvers/InlineNetLabelSolver/getAnchoredNetLabelRenderedBounds.ts +13 -0
  6. package/lib/solvers/InlineNetLabelSolver/getInlineLabelObstacles.ts +64 -0
  7. package/lib/solvers/InlineNetLabelSolver/getLocalTraceLabelShifts.ts +407 -0
  8. package/lib/solvers/InlineNetLabelSolver/getOutputLabelCollisions.ts +145 -0
  9. package/lib/solvers/InlineNetLabelSolver/isLabelRepresentedInline.ts +15 -0
  10. package/lib/solvers/InlineNetLabelSolver/pushAnchoredNetLabelsAwayFromInlineLabels.ts +61 -31
  11. package/lib/solvers/NetLabelNetLabelCollisionSolver/NetLabelNetLabelCollisionSolver.ts +94 -16
  12. package/lib/solvers/NetLabelToTraceSolver/NetLabelToTraceSolver.ts +4 -3
  13. package/lib/solvers/SameNetJunctionAlignmentSolver/alignSameNetJunctions.ts +7 -7
  14. package/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +3 -0
  15. package/lib/solvers/TraceCleanupSolver/sub-solver/generateLShapeRerouteCandidates.ts +5 -1
  16. package/lib/types/InputProblem.ts +2 -2
  17. package/package.json +1 -1
  18. package/site/SchematicTracePipelineSolver/repro-robot-controller-imu-tof.page.tsx +7 -0
  19. package/site/bug-reports/bug-report-20260907T110144Z.page.tsx +4 -0
  20. package/site/bug-reports/bug-report-20260907T145640Z.page.tsx +4 -0
  21. package/tests/bug-reports/bug-report-20260730T061837Z/__snapshots__/bug-report-20260730T061837Z.snap.svg +2 -2
  22. package/tests/bug-reports/bug-report-20260806T093501Z/__snapshots__/bug-report-20260806T093501Z.snap.svg +2 -2
  23. package/tests/bug-reports/bug-report-20260811T075142Z/__snapshots__/bug-report-20260811T075142Z.snap.svg +18 -18
  24. package/tests/bug-reports/bug-report-20260819T091818Z/__snapshots__/bug-report-20260819T091818Z.snap.svg +2 -2
  25. package/tests/bug-reports/bug-report-20260819T091818Z/bug-report-20260819T091818Z.test.ts +21 -0
  26. package/tests/bug-reports/bug-report-20260825T103621Z/__snapshots__/bug-report-20260825T103621Z.snap.svg +5 -38
  27. package/tests/bug-reports/bug-report-20260826T072956Z/__snapshots__/bug-report-20260826T072956Z.snap.svg +3 -9
  28. package/tests/bug-reports/bug-report-20260901T055358Z/__snapshots__/bug-report-20260901T055358Z.snap.svg +18 -30
  29. package/tests/bug-reports/bug-report-20260901T055358Z/bug-report-20260901T055358Z.test.ts +33 -0
  30. package/tests/bug-reports/bug-report-20260907T110144Z/__snapshots__/bug-report-20260907T110144Z.snap.svg +282 -0
  31. package/tests/bug-reports/bug-report-20260907T110144Z/bug-report-20260907T110144Z.json +1073 -0
  32. package/tests/bug-reports/bug-report-20260907T110144Z/bug-report-20260907T110144Z.test.ts +12 -0
  33. package/tests/bug-reports/bug-report-20260907T145640Z/__snapshots__/bug-report-20260907T145640Z.snap.svg +1107 -0
  34. package/tests/bug-reports/bug-report-20260907T145640Z/bug-report-20260907T145640Z.json +3563 -0
  35. package/tests/bug-reports/bug-report-20260907T145640Z/bug-report-20260907T145640Z.test.ts +117 -0
  36. package/tests/repros/__snapshots__/repro-battery-management-bq24073.snap.svg +4 -4
  37. package/tests/repros/__snapshots__/repro-board-648-esp12f-section.snap.svg +2 -2
  38. package/tests/repros/__snapshots__/repro-core-tb67s579ftg-inline-label-routing.snap.svg +20 -20
  39. package/tests/repros/__snapshots__/repro-esp12f-section.snap.svg +37 -55
  40. package/tests/repros/__snapshots__/repro-hub-usb-sheet.snap.svg +44 -68
  41. package/tests/repros/__snapshots__/repro-mspm0l1306-capacitor-symmetry.snap.svg +3 -9
  42. package/tests/repros/__snapshots__/repro-robot-controller-imu-tof.snap.svg +199 -0
  43. package/tests/repros/__snapshots__/repro-robot-controller-imu-tof.source.svg +33 -0
  44. package/tests/repros/__snapshots__/repro-usb-power-vbus-label-detour.snap.svg +50 -74
  45. package/tests/repros/__snapshots__/repro48-555-timer-vcc-rail-label.snap.svg +2 -2
  46. package/tests/repros/__snapshots__/rp2040-robot-controller-disconnected-gnd.snap.svg +103 -0
  47. package/tests/repros/assets/repro-robot-controller-imu-tof.input.json +540 -0
  48. package/tests/repros/assets/rp2040-robot-controller.input.json +349 -0
  49. package/tests/repros/repro-battery-management-bq24073.test.ts +13 -1
  50. package/tests/repros/repro-hub-usb-sheet.test.ts +25 -0
  51. package/tests/repros/repro-robot-controller-imu-tof.test.ts +83 -0
  52. package/tests/repros/repro-usb-power-vbus-label-detour.test.ts +59 -13
  53. package/tests/repros/rp2040-robot-controller-disconnected-gnd.test.ts +59 -0
  54. package/tests/solvers/InlineNetLabelSolver/get-anchored-net-label-rendered-bounds.test.ts +24 -0
  55. package/tests/solvers/InlineNetLabelSolver/local-trace-label-shifts.test.ts +296 -0
  56. package/tests/solvers/InlineNetLabelSolver/multi-pin-connected-components.test.ts +7 -4
  57. package/tests/solvers/InlineNetLabelSolver/push-anchored-net-labels-away.test.ts +2 -1
  58. package/tests/solvers/InlineNetLabelSolver/shared-direct-and-named-connection.test.ts +62 -0
  59. package/tests/solvers/InlineNetLabelSolver/two-pin-terminal-stubs-atomic.test.ts +9 -3
  60. package/tests/solvers/TraceCleanupSolver/perpendicular-detours-preserve-endpoints.test.ts +60 -0
@@ -1,3 +1,11 @@
1
+ import { getLocalTraceLabelShifts } from "./getLocalTraceLabelShifts"
2
+ import {
3
+ getOutputLabelCollisions,
4
+ sameOutputLabelCollision,
5
+ type OutputLabelCollision,
6
+ } from "./getOutputLabelCollisions"
7
+ import { getInlineLabelObstacles } from "./getInlineLabelObstacles"
8
+ import { NetLabelNetLabelCollisionSolver } from "../NetLabelNetLabelCollisionSolver/NetLabelNetLabelCollisionSolver"
1
9
  import type { Bounds, Point } from "@tscircuit/math-utils"
2
10
  import type { GraphicsObject, Rect, Text } from "graphics-debug"
3
11
  import type { ConnectivityMap } from "connectivity-map"
@@ -17,6 +25,7 @@ import type {
17
25
  } from "lib/types/InputProblem"
18
26
  import { getColorFromString } from "lib/utils/getColorFromString"
19
27
  import { boundsOverlap, getTextBoxBounds } from "lib/utils/textBoxBounds"
28
+ import { isLabelRepresentedInline } from "./isLabelRepresentedInline"
20
29
  import { alignPortOnlyInlineNetLabelStubs } from "./alignPortOnlyInlineNetLabelStubs"
21
30
  import {
22
31
  type AxisAlignedSegment,
@@ -30,7 +39,8 @@ import { restoreReroutesAroundSupersededLabels } from "./restoreReroutesAroundSu
30
39
  export const DEFAULT_INLINE_NET_LABEL_HEIGHT = 0.18
31
40
 
32
41
  /**
33
- * Gap between the trace and the near edge of the inline label text.
42
+ * Preferred gap between a trace and its inline text. A tighter parallel-wire
43
+ * slot may use a smaller, evenly divided gap if the full text still fits.
34
44
  */
35
45
  export const INLINE_NET_LABEL_TRACE_MARGIN = 0.05
36
46
 
@@ -94,6 +104,9 @@ export interface InlineNetLabelPlacement {
94
104
  }
95
105
 
96
106
  interface InlineNetLabelSolverInput {
107
+ /** Exact connector IDs supplied by their producer; IDs are never parsed. */
108
+ netLabelConnectorTraceIds?: ReadonlySet<string>
109
+ resolveTraceCollisions?: boolean
97
110
  inputProblem: InputProblem
98
111
  traces: SolvedTracePath[]
99
112
  netLabelPlacements: NetLabelPlacement[]
@@ -101,12 +114,15 @@ interface InlineNetLabelSolverInput {
101
114
  }
102
115
 
103
116
  export interface InlineNetLabelOutput {
117
+ netLabelConnectorTraceIds?: ReadonlySet<string>
104
118
  inputProblem: InputProblem
105
119
  traces: SolvedTracePath[]
106
120
  netLabelPlacements: NetLabelPlacement[]
107
121
  inlineNetLabelPlacements: InlineNetLabelPlacement[]
108
122
  }
109
123
 
124
+ type InlineOutput = Omit<InlineNetLabelOutput, "inputProblem">
125
+
110
126
  type InlineEligibleConnection = InputDirectConnection | InputNetConnection
111
127
 
112
128
  type QueuedInlineConnection =
@@ -125,13 +141,8 @@ interface NetConnectionComponent {
125
141
  traces: SolvedTracePath[]
126
142
  }
127
143
 
128
- const AVAILABLE_NET_ORIENTATION_TRACE_PREFIX = "available-net-orientation-"
129
-
130
- const isGeneratedAnchoredLabelConnector = (trace: SolvedTracePath) =>
131
- trace.mspPairId.startsWith(AVAILABLE_NET_ORIENTATION_TRACE_PREFIX)
132
-
133
144
  const getPinPairKey = (pinIds: readonly string[]) =>
134
- [...pinIds].sort().join("::")
145
+ JSON.stringify([...pinIds].sort())
135
146
 
136
147
  const getTraceLength = (trace: SolvedTracePath) => {
137
148
  let length = 0
@@ -161,12 +172,33 @@ const getInlinePlacementBounds = (
161
172
  }
162
173
  }
163
174
 
175
+ const getInlinePlacementKey = (placement: InlineNetLabelPlacement) =>
176
+ JSON.stringify([
177
+ placement.globalConnNetId,
178
+ [...placement.pinIds].sort(),
179
+ placement.mspPairId ?? null,
180
+ ])
181
+
182
+ const terminalStubIntersectsTrace = (
183
+ path: [Point, Point],
184
+ trace: SolvedTracePath,
185
+ ) => {
186
+ const clearance = INLINE_NET_LABEL_STUB_TRACE_CLEARANCE - GEOMETRY_EPSILON
187
+ const bounds = {
188
+ minX: Math.min(path[0].x, path[1].x) - clearance,
189
+ maxX: Math.max(path[0].x, path[1].x) + clearance,
190
+ minY: Math.min(path[0].y, path[1].y) - clearance,
191
+ maxY: Math.max(path[0].y, path[1].y) + clearance,
192
+ }
193
+ return doesPathIntersectBounds(trace.tracePath, bounds)
194
+ }
195
+
164
196
  /**
165
197
  * Places "inline net labels" - net names drawn alongside the trace they belong
166
198
  * to - for connections that opted in via `allowInlineNetLabel`.
167
199
  *
168
- * Any regular (anchored) net label placement for the same net is dropped, so a
169
- * net is never labeled twice.
200
+ * Anchored labels are removed only for the connected components represented
201
+ * inline. A blocked component keeps its label without undoing clear ones.
170
202
  */
171
203
  export class InlineNetLabelSolver extends BaseSolver {
172
204
  inputProblem: InputProblem
@@ -179,18 +211,29 @@ export class InlineNetLabelSolver extends BaseSolver {
179
211
  /** Eligible connections still waiting to be processed. */
180
212
  queuedConnections: QueuedInlineConnection[]
181
213
 
214
+ private readonly netLabelConnectorTraceIds: ReadonlySet<string>
182
215
  private tracesByPinPairKey: Map<string, SolvedTracePath[]>
183
216
  private globalConnMap: ConnectivityMap
184
- private supersededTraceIdsByGlobalConnNetId = new Map<string, Set<string>>()
217
+ private inlineConversions: NetConnectionInlineConversion[] = []
185
218
  private hasAlignedPortOnlyStubs = false
186
- private postProcessedOutput?: {
187
- traces: SolvedTracePath[]
188
- netLabelPlacements: NetLabelPlacement[]
189
- inlineNetLabelPlacements: InlineNetLabelPlacement[]
219
+ private postProcessedOutput?: InlineOutput
220
+ declare activeSubSolver: InlineNetLabelSolver | null
221
+ private refinementComplete = false
222
+ private refinement?: {
223
+ proposals: Generator<InlineOutput>
224
+ queue: Array<{
225
+ output: InlineOutput
226
+ distance: number
227
+ }>
228
+ collecting: boolean
229
+ before: OutputLabelCollision[]
190
230
  }
231
+ private currentProposal?: InlineOutput
191
232
 
192
- constructor(input: InlineNetLabelSolverInput) {
233
+ constructor(private readonly input: InlineNetLabelSolverInput) {
193
234
  super()
235
+ this.netLabelConnectorTraceIds =
236
+ input.netLabelConnectorTraceIds ?? new Set()
194
237
  this.inputProblem = input.inputProblem
195
238
  this.traces = input.traces
196
239
  this.inputNetLabelPlacements = input.netLabelPlacements
@@ -202,7 +245,17 @@ export class InlineNetLabelSolver extends BaseSolver {
202
245
  this.queuedConnections = [
203
246
  ...this.inputProblem.directConnections
204
247
  .filter(
205
- (connection) => connection.allowInlineNetLabel && connection.netId,
248
+ (connection) =>
249
+ connection.allowInlineNetLabel &&
250
+ connection.netId &&
251
+ // An opted-in named net owns all of its connected components.
252
+ // Do not plan the same pair again through its direct connection.
253
+ !this.inputProblem.netConnections.some(
254
+ (net) =>
255
+ net.allowInlineNetLabel &&
256
+ net.netId &&
257
+ connection.pinIds.every((pin) => net.pinIds.includes(pin)),
258
+ ),
206
259
  )
207
260
  .map((connection) => ({
208
261
  kind: "direct_connection" as const,
@@ -223,7 +276,7 @@ export class InlineNetLabelSolver extends BaseSolver {
223
276
 
224
277
  this.tracesByPinPairKey = new Map()
225
278
  for (const trace of this.traces) {
226
- if (isGeneratedAnchoredLabelConnector(trace)) continue
279
+ if (this.netLabelConnectorTraceIds.has(trace.mspPairId)) continue
227
280
  const key = getPinPairKey(trace.pins.map((p) => p.pinId))
228
281
  const existing = this.tracesByPinPairKey.get(key)
229
282
  if (existing) {
@@ -241,6 +294,8 @@ export class InlineNetLabelSolver extends BaseSolver {
241
294
  traces: this.traces,
242
295
  netLabelPlacements: this.inputNetLabelPlacements,
243
296
  completedReroutes: this.completedReroutes,
297
+ resolveTraceCollisions: this.input.resolveTraceCollisions,
298
+ netLabelConnectorTraceIds: this.netLabelConnectorTraceIds,
244
299
  },
245
300
  ]
246
301
  }
@@ -276,17 +331,7 @@ export class InlineNetLabelSolver extends BaseSolver {
276
331
  netConnections,
277
332
  )) {
278
333
  this.inlineNetLabelPlacements.push(...conversion.placements)
279
- const supersededTraceIds =
280
- this.supersededTraceIdsByGlobalConnNetId.get(
281
- conversion.globalConnNetId,
282
- ) ?? new Set<string>()
283
- for (const traceId of conversion.supersededTraceIds) {
284
- supersededTraceIds.add(traceId)
285
- }
286
- this.supersededTraceIdsByGlobalConnNetId.set(
287
- conversion.globalConnNetId,
288
- supersededTraceIds,
289
- )
334
+ this.inlineConversions.push(conversion)
290
335
  }
291
336
  return
292
337
  }
@@ -307,15 +352,15 @@ export class InlineNetLabelSolver extends BaseSolver {
307
352
  })
308
353
  if (placement) this.inlineNetLabelPlacements.push(placement)
309
354
  } else {
310
- // A one-pin net has one conventional endpoint label, while a skipped
311
- // two-pin route has one at each endpoint. Convert a two-pin pair
312
- // atomically so a collision at one endpoint cannot leave mixed inline
313
- // and anchored representations for the same net.
314
- const terminalPlacements = connection.pinIds.map((pinId) =>
315
- this.computeTerminalInlinePlacement(connection, pinId),
316
- )
317
- if (terminalPlacements.every((placement) => placement !== null)) {
318
- this.inlineNetLabelPlacements.push(...terminalPlacements)
355
+ // Unrouted endpoints are separate connected components. A blocked
356
+ // endpoint keeps its anchored tag without changing the label style at
357
+ // clear endpoints of the same named net.
358
+ for (const pinId of connection.pinIds) {
359
+ const placement = this.computeTerminalInlinePlacement(
360
+ connection,
361
+ pinId,
362
+ )
363
+ if (placement) this.inlineNetLabelPlacements.push(placement)
319
364
  }
320
365
  }
321
366
  return
@@ -337,6 +382,10 @@ export class InlineNetLabelSolver extends BaseSolver {
337
382
  return
338
383
  }
339
384
 
385
+ if (this.input.resolveTraceCollisions && !this.refinementComplete) {
386
+ this.stepTraceRefinement()
387
+ return
388
+ }
340
389
  this.solved = true
341
390
  this.stats.inlineNetLabelCount = this.inlineNetLabelPlacements.length
342
391
  }
@@ -344,7 +393,7 @@ export class InlineNetLabelSolver extends BaseSolver {
344
393
  /**
345
394
  * Plans all opted-in multi-pin nets together. Short routed components can
346
395
  * mutually block the outward endpoint stubs that replace them, so tentative
347
- * replacements are ignored as obstacles only while their owning net still
396
+ * replacements are ignored as obstacles only while their connected component still
348
397
  * converts successfully. Failed conversions retain both their traces and
349
398
  * their conventional labels, and the remaining conversions are retried.
350
399
  */
@@ -392,13 +441,11 @@ export class InlineNetLabelSolver extends BaseSolver {
392
441
  }))
393
442
  const nextIgnoredTraceIds = new Set(
394
443
  conversions.flatMap(({ conversion }) =>
395
- conversion ? [...conversion.supersededTraceIds] : [],
444
+ conversion.flatMap((component) => [...component.supersededTraceIds]),
396
445
  ),
397
446
  )
398
447
  if (setsEqual(ignoredTraceIds, nextIgnoredTraceIds)) {
399
- return conversions.flatMap(({ conversion }) =>
400
- conversion ? [conversion] : [],
401
- )
448
+ return conversions.flatMap(({ conversion }) => conversion)
402
449
  }
403
450
  ignoredTraceIds = nextIgnoredTraceIds
404
451
  }
@@ -436,7 +483,7 @@ export class InlineNetLabelSolver extends BaseSolver {
436
483
  // anchored label. They are removed when that label becomes inline, so
437
484
  // treating them as routed circuitry would leave text on a deleted wire.
438
485
  traces: this.traces.filter(
439
- (trace) => !isGeneratedAnchoredLabelConnector(trace),
486
+ (trace) => !this.netLabelConnectorTraceIds.has(trace.mspPairId),
440
487
  ),
441
488
  })
442
489
  .map((component) => ({
@@ -473,19 +520,19 @@ export class InlineNetLabelSolver extends BaseSolver {
473
520
  * Converts every conventional label representation for an opted-in named
474
521
  * net. Routed connected components become labels on their representative
475
522
  * trace; disconnected pins become outward terminal stubs. The conversion is
476
- * atomic so one blocked candidate cannot leave mixed label styles on a net.
523
+ * atomic within each routed component, so replacing its traces cannot strand
524
+ * a pin. Disconnected components can keep their own label style.
477
525
  */
478
526
  private computeNetConnectionInlinePlacements(
479
527
  connection: InputNetConnection,
480
528
  ignoredTraceIds: Set<string>,
481
529
  forcedTerminalTraceIds: Set<string>,
482
- ): NetConnectionInlineConversion | null {
530
+ ): NetConnectionInlineConversion[] {
483
531
  const components = this.getNetConnectionComponents(connection)
484
532
  const globalConnNetId = this.getGlobalConnNetId(connection)
485
- if (!globalConnNetId) return null
533
+ if (!globalConnNetId) return []
486
534
 
487
- const inlinePlacements: InlineNetLabelPlacement[] = []
488
- const supersededTraceIds = new Set<string>()
535
+ const conversions: NetConnectionInlineConversion[] = []
489
536
  for (const component of components) {
490
537
  const shouldUseTerminalStubs = component.traces.some((trace) =>
491
538
  forcedTerminalTraceIds.has(trace.mspPairId),
@@ -504,21 +551,25 @@ export class InlineNetLabelSolver extends BaseSolver {
504
551
  }
505
552
  }
506
553
  if (inlinePlacement) {
507
- inlinePlacements.push(inlinePlacement)
554
+ conversions.push({
555
+ globalConnNetId,
556
+ placements: [inlinePlacement],
557
+ supersededTraceIds: new Set(),
558
+ })
508
559
  continue
509
560
  }
510
561
  if (component.traces.length > 0 && !shouldUseTerminalStubs) {
511
562
  // This route originally had a valid inline placement, but a trace from
512
- // a conversion that had to be rolled back now blocks it. Roll back
513
- // this whole net as well instead of introducing a new replacement and
563
+ // a conversion that had to be rolled back now blocks it. Retain
564
+ // this component as well instead of introducing a new replacement and
514
565
  // allowing the batch plan to oscillate.
515
- return null
566
+ continue
516
567
  }
517
568
  if (component.labeledPinIds.length !== component.pinIds.length) {
518
569
  // Removing this component's traces would strand an intermediate pin
519
570
  // that was connected through the global net but was not an endpoint of
520
571
  // this named connection. Retain the original routed representation.
521
- return null
572
+ continue
522
573
  }
523
574
 
524
575
  // A routed component may be too short to carry its text. In that case,
@@ -534,33 +585,69 @@ export class InlineNetLabelSolver extends BaseSolver {
534
585
  ),
535
586
  )
536
587
  if (terminalPlacements.some((placement) => placement === null)) {
537
- return null
588
+ continue
538
589
  }
539
- inlinePlacements.push(
540
- ...terminalPlacements.filter(
541
- (placement): placement is InlineNetLabelPlacement =>
542
- placement !== null,
590
+ conversions.push({
591
+ globalConnNetId,
592
+ placements: terminalPlacements as InlineNetLabelPlacement[],
593
+ supersededTraceIds: new Set(
594
+ component.traces.map((trace) => trace.mspPairId),
543
595
  ),
544
- )
545
- for (const trace of component.traces) {
546
- supersededTraceIds.add(trace.mspPairId)
547
- }
596
+ })
548
597
  }
549
598
 
550
- return { globalConnNetId, placements: inlinePlacements, supersededTraceIds }
599
+ return conversions
600
+ }
601
+
602
+ /** Prefer the normal gap, then center text in a tighter parallel-wire slot. */
603
+ private getInlineOffsets(
604
+ anchor: Point,
605
+ axis: "x" | "y",
606
+ side: InlineNetLabelPlacement["side"],
607
+ width: number,
608
+ height: number,
609
+ traces: SolvedTracePath[],
610
+ ignoredTraceIds: Set<string>,
611
+ ): number[] {
612
+ const normal = height / 2 + INLINE_NET_LABEL_TRACE_MARGIN
613
+ const across = axis === "x" ? "y" : "x"
614
+ const sign = side === "x+" || side === "y+" ? 1 : -1
615
+ let gap = Infinity
616
+ for (const trace of traces) {
617
+ if (ignoredTraceIds.has(trace.mspPairId)) continue
618
+ for (const segment of getAxisAlignedSegments(trace.tracePath)) {
619
+ if (segment.axis !== axis) continue
620
+ if (
621
+ Math.max(segment.start[axis], segment.end[axis]) <=
622
+ anchor[axis] - width / 2 ||
623
+ Math.min(segment.start[axis], segment.end[axis]) >=
624
+ anchor[axis] + width / 2
625
+ )
626
+ continue
627
+ const distance = sign * (segment.start[across] - anchor[across])
628
+ if (distance > GEOMETRY_EPSILON) gap = Math.min(gap, distance)
629
+ }
630
+ }
631
+ return gap > height + GEOMETRY_EPSILON &&
632
+ gap / 2 < normal - GEOMETRY_EPSILON
633
+ ? [normal, gap / 2]
634
+ : [normal]
551
635
  }
552
636
 
553
637
  /**
554
- * Converts one conventional endpoint placement into an inline label on a
555
- * generated outward stub. The stub follows the pin's true facing direction;
556
- * an anchored label may finish in another direction after an elbow, which is
557
- * not the direction a terminal stub should leave the pin.
638
+ * Converts one conventional endpoint placement into an inline label on an
639
+ * outward stub that follows the pin's true facing direction.
558
640
  */
559
641
  private computeTerminalInlinePlacement(
560
642
  connection: InlineEligibleConnection,
561
643
  pinId: PinId,
562
644
  knownGlobalConnNetId?: string,
563
645
  ignoredTraceIds = new Set<string>(),
646
+ obstacles?: {
647
+ traces: SolvedTracePath[]
648
+ anchored: NetLabelPlacement[]
649
+ inline: InlineNetLabelPlacement[]
650
+ },
564
651
  ): InlineNetLabelPlacement | null {
565
652
  if (!connection.netId) return null
566
653
 
@@ -611,7 +698,7 @@ export class InlineNetLabelSolver extends BaseSolver {
611
698
  start,
612
699
  intendedEnd,
613
700
  minimumLength: width + 2 * INLINE_NET_LABEL_TRACE_MARGIN,
614
- traces: this.traces,
701
+ traces: obstacles?.traces ?? this.traces,
615
702
  ownGlobalConnNetId: globalConnNetId,
616
703
  ignoredTraceIds,
617
704
  })
@@ -623,61 +710,70 @@ export class InlineNetLabelSolver extends BaseSolver {
623
710
  x: (start.x + end.x) / 2,
624
711
  y: (start.y + end.y) / 2,
625
712
  }
626
- const offset = height / 2 + INLINE_NET_LABEL_TRACE_MARGIN
627
713
  const sides: InlineNetLabelPlacement["side"][] =
628
714
  axis === "x" ? ["y+", "y-"] : ["x-", "x+"]
629
715
 
630
716
  for (const side of sides) {
631
- const center: Point =
632
- side === "y+"
633
- ? { x: anchorPoint.x, y: anchorPoint.y + offset }
634
- : side === "y-"
635
- ? { x: anchorPoint.x, y: anchorPoint.y - offset }
636
- : side === "x-"
637
- ? { x: anchorPoint.x - offset, y: anchorPoint.y }
638
- : { x: anchorPoint.x + offset, y: anchorPoint.y }
639
-
640
- const halfAlong = width / 2
641
- const halfAcross = height / 2
642
- const bounds: Bounds =
643
- axis === "x"
644
- ? {
645
- minX: center.x - halfAlong,
646
- maxX: center.x + halfAlong,
647
- minY: center.y - halfAcross,
648
- maxY: center.y + halfAcross,
649
- }
650
- : {
651
- minX: center.x - halfAcross,
652
- maxX: center.x + halfAcross,
653
- minY: center.y - halfAlong,
654
- maxY: center.y + halfAlong,
655
- }
656
-
657
- if (
658
- this.isObstructed(bounds, {
659
- ownGlobalConnNetId: globalConnNetId,
660
- ignoredTraceIds,
661
- })
662
- ) {
663
- continue
664
- }
665
-
666
- return {
667
- globalConnNetId,
668
- netId: connection.netId,
669
- netLabelText,
670
- pinIds: [pinId],
671
- stubTracePath: [start, end],
672
- axis,
717
+ for (const offset of this.getInlineOffsets(
673
718
  anchorPoint,
674
- center,
719
+ axis,
720
+ side,
675
721
  width,
676
722
  height,
677
- side,
723
+ obstacles?.traces ?? this.traces,
724
+ ignoredTraceIds,
725
+ )) {
726
+ const center: Point =
727
+ side === "y+"
728
+ ? { x: anchorPoint.x, y: anchorPoint.y + offset }
729
+ : side === "y-"
730
+ ? { x: anchorPoint.x, y: anchorPoint.y - offset }
731
+ : side === "x-"
732
+ ? { x: anchorPoint.x - offset, y: anchorPoint.y }
733
+ : { x: anchorPoint.x + offset, y: anchorPoint.y }
734
+
735
+ const halfAlong = width / 2
736
+ const halfAcross = height / 2
737
+ const bounds: Bounds =
738
+ axis === "x"
739
+ ? {
740
+ minX: center.x - halfAlong,
741
+ maxX: center.x + halfAlong,
742
+ minY: center.y - halfAcross,
743
+ maxY: center.y + halfAcross,
744
+ }
745
+ : {
746
+ minX: center.x - halfAcross,
747
+ maxX: center.x + halfAcross,
748
+ minY: center.y - halfAlong,
749
+ maxY: center.y + halfAlong,
750
+ }
751
+
752
+ if (
753
+ this.isObstructed(bounds, {
754
+ ownGlobalConnNetId: globalConnNetId,
755
+ ignoredTraceIds,
756
+ obstacles,
757
+ })
758
+ ) {
759
+ continue
760
+ }
761
+
762
+ return {
763
+ globalConnNetId,
764
+ netId: connection.netId,
765
+ netLabelText,
766
+ pinIds: [pinId],
767
+ stubTracePath: [start, end],
768
+ axis,
769
+ anchorPoint,
770
+ center,
771
+ width,
772
+ height,
773
+ side,
774
+ }
678
775
  }
679
776
  }
680
-
681
777
  return null
682
778
  }
683
779
 
@@ -722,53 +818,63 @@ export class InlineNetLabelSolver extends BaseSolver {
722
818
 
723
819
  for (const side of sides) {
724
820
  for (const anchorPoint of getAnchorCandidates(segment, width)) {
725
- const center =
726
- side === "y+"
727
- ? { x: anchorPoint.x, y: anchorPoint.y + offset }
728
- : side === "y-"
729
- ? { x: anchorPoint.x, y: anchorPoint.y - offset }
730
- : side === "x-"
731
- ? { x: anchorPoint.x - offset, y: anchorPoint.y }
732
- : { x: anchorPoint.x + offset, y: anchorPoint.y }
733
-
734
- const halfAlong = width / 2
735
- const halfAcross = height / 2
736
- const bounds: Bounds =
737
- segment.axis === "x"
738
- ? {
739
- minX: center.x - halfAlong,
740
- maxX: center.x + halfAlong,
741
- minY: center.y - halfAcross,
742
- maxY: center.y + halfAcross,
743
- }
744
- : {
745
- minX: center.x - halfAcross,
746
- maxX: center.x + halfAcross,
747
- minY: center.y - halfAlong,
748
- maxY: center.y + halfAlong,
749
- }
750
-
751
- if (
752
- this.isObstructed(bounds, {
753
- ownTrace: trace,
754
- ownGlobalConnNetId: trace.globalConnNetId,
755
- ignoredTraceIds,
756
- })
757
- )
758
- continue
759
-
760
- return {
761
- globalConnNetId: trace.globalConnNetId,
762
- netId: connection.netId,
763
- netLabelText,
764
- mspPairId: trace.mspPairId,
765
- pinIds: [...pinIds],
766
- axis: segment.axis,
821
+ for (const offset of this.getInlineOffsets(
767
822
  anchorPoint,
768
- center,
823
+ segment.axis,
824
+ side,
769
825
  width,
770
826
  height,
771
- side,
827
+ this.traces,
828
+ ignoredTraceIds,
829
+ )) {
830
+ const center =
831
+ side === "y+"
832
+ ? { x: anchorPoint.x, y: anchorPoint.y + offset }
833
+ : side === "y-"
834
+ ? { x: anchorPoint.x, y: anchorPoint.y - offset }
835
+ : side === "x-"
836
+ ? { x: anchorPoint.x - offset, y: anchorPoint.y }
837
+ : { x: anchorPoint.x + offset, y: anchorPoint.y }
838
+
839
+ const halfAlong = width / 2
840
+ const halfAcross = height / 2
841
+ const bounds: Bounds =
842
+ segment.axis === "x"
843
+ ? {
844
+ minX: center.x - halfAlong,
845
+ maxX: center.x + halfAlong,
846
+ minY: center.y - halfAcross,
847
+ maxY: center.y + halfAcross,
848
+ }
849
+ : {
850
+ minX: center.x - halfAcross,
851
+ maxX: center.x + halfAcross,
852
+ minY: center.y - halfAlong,
853
+ maxY: center.y + halfAlong,
854
+ }
855
+
856
+ if (
857
+ this.isObstructed(bounds, {
858
+ ownTrace: trace,
859
+ ownGlobalConnNetId: trace.globalConnNetId,
860
+ ignoredTraceIds,
861
+ })
862
+ )
863
+ continue
864
+
865
+ return {
866
+ globalConnNetId: trace.globalConnNetId,
867
+ netId: connection.netId,
868
+ netLabelText,
869
+ mspPairId: trace.mspPairId,
870
+ pinIds: [...pinIds],
871
+ axis: segment.axis,
872
+ anchorPoint,
873
+ center,
874
+ width,
875
+ height,
876
+ side,
877
+ }
772
878
  }
773
879
  }
774
880
  }
@@ -933,10 +1039,16 @@ export class InlineNetLabelSolver extends BaseSolver {
933
1039
  ownTrace,
934
1040
  ownGlobalConnNetId,
935
1041
  ignoredTraceIds = new Set<string>(),
1042
+ obstacles,
936
1043
  }: {
937
1044
  ownTrace?: SolvedTracePath
938
1045
  ownGlobalConnNetId: string
939
1046
  ignoredTraceIds?: Set<string>
1047
+ obstacles?: {
1048
+ traces: SolvedTracePath[]
1049
+ anchored: NetLabelPlacement[]
1050
+ inline: InlineNetLabelPlacement[]
1051
+ }
940
1052
  },
941
1053
  ): boolean {
942
1054
  for (const chip of this.inputProblem.chips) {
@@ -953,109 +1065,154 @@ export class InlineNetLabelSolver extends BaseSolver {
953
1065
  if (boundsOverlap(bounds, getTextBoxBounds(textBox))) return true
954
1066
  }
955
1067
 
956
- for (const trace of this.traces) {
1068
+ for (const trace of obstacles?.traces ?? this.traces) {
957
1069
  if (ignoredTraceIds.has(trace.mspPairId)) continue
958
1070
  if (ownTrace && trace.mspPairId === ownTrace.mspPairId) continue
959
1071
  if (trace.globalConnNetId === ownGlobalConnNetId) continue
960
1072
  if (doesPathIntersectBounds(trace.tracePath, bounds)) return true
961
1073
  }
962
1074
 
1075
+ if (
1076
+ obstacles?.anchored.some((label) =>
1077
+ boundsOverlap(bounds, getAnchoredNetLabelRenderedBounds(label)),
1078
+ )
1079
+ )
1080
+ return true
1081
+ if (
1082
+ obstacles?.inline.some(
1083
+ (label) =>
1084
+ boundsOverlap(bounds, getInlinePlacementBounds(label)) ||
1085
+ (label.stubTracePath &&
1086
+ label.globalConnNetId !== ownGlobalConnNetId &&
1087
+ doesPathIntersectBounds(label.stubTracePath, bounds)),
1088
+ )
1089
+ )
1090
+ return true
963
1091
  return false
964
1092
  }
965
1093
 
966
- /**
967
- * Net label placements superseded by an inline label. A net gets one label or
968
- * the other, never both.
969
- */
970
- private getSupersededNetLabelKeys(
971
- placements = this.inlineNetLabelPlacements,
972
- ): Set<string> {
973
- const keys = new Set<string>()
974
- for (const placement of placements) {
975
- keys.add(placement.globalConnNetId)
976
- }
977
- return keys
978
- }
979
-
980
- private getOutputTraces(
981
- supersededNetLabelKeys: Set<string>,
982
- supersededTraceIds: Set<string>,
983
- ) {
1094
+ private getOutputTraces(placements: InlineNetLabelPlacement[]) {
1095
+ const activeKeys = new Set(placements.map(getInlinePlacementKey))
1096
+ const supersededTraceIds = new Set(
1097
+ this.inlineConversions
1098
+ .filter((conversion) =>
1099
+ conversion.placements.every((placement) =>
1100
+ activeKeys.has(getInlinePlacementKey(placement)),
1101
+ ),
1102
+ )
1103
+ .flatMap((conversion) => [...conversion.supersededTraceIds]),
1104
+ )
984
1105
  return this.traces.filter(
985
1106
  (trace) =>
986
1107
  !supersededTraceIds.has(trace.mspPairId) &&
987
1108
  !(
988
- supersededNetLabelKeys.has(trace.globalConnNetId) &&
989
- isGeneratedAnchoredLabelConnector(trace)
1109
+ this.netLabelConnectorTraceIds.has(trace.mspPairId) &&
1110
+ isLabelRepresentedInline(trace, placements)
990
1111
  ),
991
1112
  )
992
1113
  }
993
1114
 
994
- private getInlineGlobalsOverlappingAnchoredLabels({
1115
+ private getBlockedInlinePlacements({
995
1116
  inlineNetLabelPlacements,
996
1117
  anchoredNetLabelPlacements,
1118
+ traces,
997
1119
  }: {
998
1120
  inlineNetLabelPlacements: InlineNetLabelPlacement[]
999
1121
  anchoredNetLabelPlacements: NetLabelPlacement[]
1122
+ traces: SolvedTracePath[]
1000
1123
  }): Set<string> {
1001
- const blockedGlobalConnNetIds = new Set<string>()
1002
- for (const inlinePlacement of inlineNetLabelPlacements) {
1003
- const inlineBounds = getInlinePlacementBounds(inlinePlacement)
1004
- for (const anchoredPlacement of anchoredNetLabelPlacements) {
1005
- if (
1006
- anchoredPlacement.globalConnNetId === inlinePlacement.globalConnNetId
1007
- ) {
1008
- continue
1009
- }
1010
- const anchoredBounds =
1011
- getAnchoredNetLabelRenderedBounds(anchoredPlacement)
1012
- if (
1013
- boundsOverlap(inlineBounds, anchoredBounds) ||
1014
- (inlinePlacement.stubTracePath &&
1015
- doesPathIntersectBounds(
1016
- inlinePlacement.stubTracePath,
1017
- anchoredBounds,
1018
- ))
1019
- ) {
1020
- blockedGlobalConnNetIds.add(inlinePlacement.globalConnNetId)
1021
- break
1022
- }
1124
+ const blockedKeys = new Set<string>()
1125
+ for (const inline of inlineNetLabelPlacements) {
1126
+ const bounds = getInlinePlacementBounds(inline)
1127
+ const blockedByLabel = anchoredNetLabelPlacements.some((anchored) => {
1128
+ const anchoredBounds = getAnchoredNetLabelRenderedBounds(anchored)
1129
+ return (
1130
+ boundsOverlap(bounds, anchoredBounds) ||
1131
+ (inline.stubTracePath &&
1132
+ doesPathIntersectBounds(inline.stubTracePath, anchoredBounds))
1133
+ )
1134
+ })
1135
+ // Rolling back a routed component restores its original wires. Recheck
1136
+ // other inline conversions against those wires before accepting them.
1137
+ const blockedByTrace = traces.some(
1138
+ (trace) =>
1139
+ trace.globalConnNetId !== inline.globalConnNetId &&
1140
+ (doesPathIntersectBounds(trace.tracePath, bounds) ||
1141
+ (inline.stubTracePath &&
1142
+ terminalStubIntersectsTrace(inline.stubTracePath, trace))),
1143
+ )
1144
+ const blockedByInlineWire = inlineNetLabelPlacements.some(
1145
+ (other) =>
1146
+ other !== inline &&
1147
+ other.globalConnNetId !== inline.globalConnNetId &&
1148
+ other.stubTracePath &&
1149
+ doesPathIntersectBounds(other.stubTracePath, bounds),
1150
+ )
1151
+ const blockedByInlineText = inlineNetLabelPlacements.some(
1152
+ (other) =>
1153
+ other !== inline &&
1154
+ boundsOverlap(bounds, getInlinePlacementBounds(other)),
1155
+ )
1156
+ if (
1157
+ blockedByLabel ||
1158
+ blockedByTrace ||
1159
+ blockedByInlineWire ||
1160
+ blockedByInlineText
1161
+ )
1162
+ blockedKeys.add(getInlinePlacementKey(inline))
1163
+ }
1164
+ for (const conversion of this.inlineConversions) {
1165
+ if (
1166
+ conversion.placements.some((placement) =>
1167
+ blockedKeys.has(getInlinePlacementKey(placement)),
1168
+ )
1169
+ ) {
1170
+ for (const placement of conversion.placements)
1171
+ blockedKeys.add(getInlinePlacementKey(placement))
1023
1172
  }
1024
1173
  }
1025
- return blockedGlobalConnNetIds
1174
+ return blockedKeys
1026
1175
  }
1027
1176
 
1028
- private buildPostProcessedOutput() {
1177
+ private buildPostProcessedOutput(): InlineOutput {
1029
1178
  let activeInlinePlacements = this.inlineNetLabelPlacements
1030
1179
  while (true) {
1031
- const supersededNetLabelKeys = this.getSupersededNetLabelKeys(
1032
- activeInlinePlacements,
1033
- )
1034
- const supersededTraceIds = new Set(
1035
- [...supersededNetLabelKeys].flatMap((globalConnNetId) => [
1036
- ...(this.supersededTraceIdsByGlobalConnNetId.get(globalConnNetId) ??
1037
- []),
1038
- ]),
1039
- )
1040
1180
  const retainedNetLabelPlacements = this.inputNetLabelPlacements.filter(
1041
- (placement) => !supersededNetLabelKeys.has(placement.globalConnNetId),
1181
+ (placement) =>
1182
+ !isLabelRepresentedInline(placement, activeInlinePlacements),
1042
1183
  )
1043
- const outputTraces = this.getOutputTraces(
1044
- supersededNetLabelKeys,
1045
- supersededTraceIds,
1184
+ const outputTraces = this.getOutputTraces(activeInlinePlacements)
1185
+ // Replacing tags frees space on neighboring routed nets. Reconsider
1186
+ // their anchors with the inline text and terminal wires held fixed.
1187
+ const { fixedLabels, terminalTraces } = getInlineLabelObstacles(
1188
+ this.inputProblem,
1189
+ activeInlinePlacements,
1046
1190
  )
1191
+ const labelCollisionSolver = new NetLabelNetLabelCollisionSolver({
1192
+ inputProblem: this.inputProblem,
1193
+ traces: [...outputTraces, ...terminalTraces],
1194
+ netLabelPlacements: retainedNetLabelPlacements,
1195
+ fixedNetLabelPlacements: fixedLabels,
1196
+ netLabelConnectorTraceIds: this.netLabelConnectorTraceIds,
1197
+ useRenderedLabelBounds: activeInlinePlacements.length > 0,
1198
+ })
1199
+ if (activeInlinePlacements.length > 0) labelCollisionSolver.solve()
1047
1200
  const pushed = pushAnchoredNetLabelsAwayFromInlineLabels({
1048
1201
  inputProblem: this.inputProblem,
1049
1202
  traces: outputTraces,
1050
- netLabelPlacements: retainedNetLabelPlacements,
1203
+ netLabelPlacements:
1204
+ labelCollisionSolver.solved && !labelCollisionSolver.failed
1205
+ ? labelCollisionSolver.getOutput().netLabelPlacements
1206
+ : retainedNetLabelPlacements,
1051
1207
  inlineNetLabelPlacements: activeInlinePlacements,
1208
+ netLabelConnectorTraceIds: this.netLabelConnectorTraceIds,
1052
1209
  })
1053
- const blockedGlobalConnNetIds =
1054
- this.getInlineGlobalsOverlappingAnchoredLabels({
1055
- inlineNetLabelPlacements: activeInlinePlacements,
1056
- anchoredNetLabelPlacements: pushed.netLabelPlacements,
1057
- })
1058
- if (blockedGlobalConnNetIds.size > 0) {
1210
+ let blockedPlacementKeys = this.getBlockedInlinePlacements({
1211
+ inlineNetLabelPlacements: activeInlinePlacements,
1212
+ anchoredNetLabelPlacements: pushed.netLabelPlacements,
1213
+ traces: pushed.traces,
1214
+ })
1215
+ if (blockedPlacementKeys.size > 0) {
1059
1216
  const shiftedInlineTerminalLabels =
1060
1217
  pushInlineTerminalLabelsAwayFromAnchoredLabels({
1061
1218
  inputProblem: this.inputProblem,
@@ -1068,8 +1225,60 @@ export class InlineNetLabelSolver extends BaseSolver {
1068
1225
  shiftedInlineTerminalLabels.inlineNetLabelPlacements
1069
1226
  continue
1070
1227
  }
1228
+ // Obstacles changed after anchor placement. Reuse the existing side
1229
+ // search, starting with obstructed terminals. Keep every terminal wire
1230
+ // fixed as an obstacle while selecting text positions.
1231
+ const { terminalTraces } = getInlineLabelObstacles(
1232
+ this.inputProblem,
1233
+ activeInlinePlacements,
1234
+ )
1235
+ const replanned = activeInlinePlacements.filter(
1236
+ (label) => !label.stubTracePath,
1237
+ )
1238
+ const terminals = activeInlinePlacements
1239
+ .filter((label) => label.stubTracePath)
1240
+ .sort(
1241
+ (a, b) =>
1242
+ Number(blockedPlacementKeys.has(getInlinePlacementKey(b))) -
1243
+ Number(blockedPlacementKeys.has(getInlinePlacementKey(a))),
1244
+ )
1245
+ for (const placement of terminals) {
1246
+ const connection = [
1247
+ ...this.inputProblem.netConnections,
1248
+ ...this.inputProblem.directConnections,
1249
+ ].find(
1250
+ (connection) =>
1251
+ connection.netId === placement.netId &&
1252
+ connection.allowInlineNetLabel,
1253
+ )
1254
+ const candidate = connection
1255
+ ? this.computeTerminalInlinePlacement(
1256
+ connection,
1257
+ placement.pinIds[0]!,
1258
+ placement.globalConnNetId,
1259
+ new Set(),
1260
+ {
1261
+ traces: [...pushed.traces, ...terminalTraces],
1262
+ anchored: pushed.netLabelPlacements,
1263
+ inline: replanned,
1264
+ },
1265
+ )
1266
+ : null
1267
+ replanned.push(candidate ?? placement)
1268
+ }
1269
+ const byKey = new Map(
1270
+ replanned.map((label) => [getInlinePlacementKey(label), label]),
1271
+ )
1272
+ activeInlinePlacements = activeInlinePlacements.map(
1273
+ (label) => byKey.get(getInlinePlacementKey(label)) ?? label,
1274
+ )
1275
+ blockedPlacementKeys = this.getBlockedInlinePlacements({
1276
+ inlineNetLabelPlacements: activeInlinePlacements,
1277
+ anchoredNetLabelPlacements: pushed.netLabelPlacements,
1278
+ traces: pushed.traces,
1279
+ })
1071
1280
  }
1072
- if (blockedGlobalConnNetIds.size === 0) {
1281
+ if (blockedPlacementKeys.size === 0) {
1073
1282
  this.inlineNetLabelPlacements = activeInlinePlacements
1074
1283
  this.stats.pushedAnchoredNetLabelCount = pushed.movedLabelCount
1075
1284
  const restored = restoreReroutesAroundSupersededLabels({
@@ -1081,19 +1290,186 @@ export class InlineNetLabelSolver extends BaseSolver {
1081
1290
  })
1082
1291
  this.stats.restoredSupersededLabelRerouteCount =
1083
1292
  restored.restoredTraceCount
1084
- return {
1293
+ const output = {
1085
1294
  traces: restored.traces,
1295
+ netLabelConnectorTraceIds: pushed.netLabelConnectorTraceIds,
1086
1296
  netLabelPlacements: pushed.netLabelPlacements,
1087
1297
  inlineNetLabelPlacements: activeInlinePlacements,
1088
1298
  }
1299
+ this.inlineNetLabelPlacements = output.inlineNetLabelPlacements
1300
+ return output
1089
1301
  }
1090
1302
  activeInlinePlacements = activeInlinePlacements.filter(
1091
- (placement) => !blockedGlobalConnNetIds.has(placement.globalConnNetId),
1303
+ (placement) =>
1304
+ !blockedPlacementKeys.has(getInlinePlacementKey(placement)),
1305
+ )
1306
+ }
1307
+ }
1308
+
1309
+ /** Enumerate nearby moves in the existing order; collect one proposal per step. */
1310
+ private *getTraceRefinementProposals(current: InlineOutput) {
1311
+ yield* getLocalTraceLabelShifts(this.inputProblem, current)
1312
+ // An anchored endpoint can hide the larger footprint of its requested
1313
+ // inline label. Consider that footprint without changing caller opt-ins.
1314
+ for (const label of current.netLabelPlacements) {
1315
+ if (label.pinIds.length !== 1) continue
1316
+ const connection = [
1317
+ ...this.inputProblem.netConnections,
1318
+ ...this.inputProblem.directConnections,
1319
+ ].find(
1320
+ (connection) =>
1321
+ connection.allowInlineNetLabel &&
1322
+ connection.netId === label.netId &&
1323
+ connection.pinIds.includes(label.pinIds[0]!),
1092
1324
  )
1325
+ if (!connection) continue
1326
+ const placement = this.computeTerminalInlinePlacement(
1327
+ connection,
1328
+ label.pinIds[0]!,
1329
+ label.globalConnNetId,
1330
+ new Set(),
1331
+ { traces: [], anchored: [], inline: [] },
1332
+ )
1333
+ if (placement)
1334
+ yield* getLocalTraceLabelShifts(this.inputProblem, current, [placement])
1335
+ }
1336
+ }
1337
+
1338
+ private createRefinementCandidate(
1339
+ proposal: InlineOutput,
1340
+ ): InlineNetLabelSolver {
1341
+ const traceMap = new Map(
1342
+ proposal.traces.map((trace) => [trace.mspPairId, trace]),
1343
+ )
1344
+ const labelKey = (label: NetLabelPlacement) =>
1345
+ JSON.stringify([label.globalConnNetId, [...label.pinIds].sort()])
1346
+ const labelMap = new Map(
1347
+ proposal.netLabelPlacements.map((label) => [labelKey(label), label]),
1348
+ )
1349
+ const originalTraceIds = new Set(
1350
+ this.traces.map((trace) => trace.mspPairId),
1351
+ )
1352
+ return new InlineNetLabelSolver({
1353
+ inputProblem: this.inputProblem,
1354
+ traces: [
1355
+ ...this.traces.map((trace) => traceMap.get(trace.mspPairId) ?? trace),
1356
+ ...proposal.traces.filter(
1357
+ (trace) => !originalTraceIds.has(trace.mspPairId),
1358
+ ),
1359
+ ],
1360
+ netLabelPlacements: this.inputNetLabelPlacements.map(
1361
+ (label) => labelMap.get(labelKey(label)) ?? label,
1362
+ ),
1363
+ completedReroutes: this.completedReroutes,
1364
+ netLabelConnectorTraceIds: proposal.netLabelConnectorTraceIds,
1365
+ })
1366
+ }
1367
+
1368
+ private acceptRefinementCandidate(
1369
+ proposal: InlineOutput,
1370
+ result: InlineOutput,
1371
+ ): boolean {
1372
+ const current = this.postProcessedOutput!
1373
+ const before = this.refinement!.before
1374
+ const after = getOutputLabelCollisions(result)
1375
+ if (
1376
+ after.some(
1377
+ (collision) =>
1378
+ !before.some((previous) =>
1379
+ sameOutputLabelCollision(collision, previous),
1380
+ ),
1381
+ )
1382
+ )
1383
+ return false
1384
+ const movedTraceIds = new Set(
1385
+ proposal.traces
1386
+ .filter((trace, index) => trace !== current.traces[index])
1387
+ .map((trace) => trace.mspPairId),
1388
+ )
1389
+ // Every moved route must clear its collisions, using its exact identity.
1390
+ if (
1391
+ after.some(
1392
+ (collision) =>
1393
+ collision.kind === "trace-label" &&
1394
+ collision.trace.kind === "routed" &&
1395
+ movedTraceIds.has(collision.trace.id),
1396
+ )
1397
+ )
1398
+ return false
1399
+ if (
1400
+ after.length >= before.length &&
1401
+ result.inlineNetLabelPlacements.length <=
1402
+ current.inlineNetLabelPlacements.length
1403
+ )
1404
+ return false
1405
+ this.postProcessedOutput = result
1406
+ this.inlineNetLabelPlacements = result.inlineNetLabelPlacements
1407
+ return true
1408
+ }
1409
+
1410
+ private stepTraceRefinement() {
1411
+ if (this.activeSubSolver) {
1412
+ this.activeSubSolver.step()
1413
+ if (!this.activeSubSolver.solved && !this.activeSubSolver.failed) return
1414
+ // A failed or exhausted solver cannot replace the last accepted output.
1415
+ if (
1416
+ this.activeSubSolver.solved &&
1417
+ !this.activeSubSolver.failed &&
1418
+ this.acceptRefinementCandidate(
1419
+ this.currentProposal!,
1420
+ this.activeSubSolver.getOutput(),
1421
+ )
1422
+ ) {
1423
+ this.refinement = undefined
1424
+ }
1425
+ this.activeSubSolver = null
1426
+ this.currentProposal = undefined
1427
+ return
1428
+ }
1429
+ const current = this.postProcessedOutput!
1430
+ if (!this.refinement) {
1431
+ this.refinement = {
1432
+ proposals: this.getTraceRefinementProposals(current),
1433
+ queue: [],
1434
+ collecting: true,
1435
+ before: getOutputLabelCollisions(current),
1436
+ }
1437
+ }
1438
+ const refinement = this.refinement
1439
+ if (refinement.collecting) {
1440
+ const next = refinement.proposals.next()
1441
+ if (!next.done) {
1442
+ const distance = next.value.traces.reduce(
1443
+ (sum, trace, index) =>
1444
+ sum +
1445
+ trace.tracePath.reduce((distance, point, pointIndex) => {
1446
+ const previous = current.traces[index]!.tracePath[pointIndex]!
1447
+ return (
1448
+ distance +
1449
+ Math.abs(point.x - previous.x) +
1450
+ Math.abs(point.y - previous.y)
1451
+ )
1452
+ }, 0),
1453
+ 0,
1454
+ )
1455
+ refinement.queue.push({ output: next.value, distance })
1456
+ } else {
1457
+ // Keep the previous smallest-move-first ordering, including stable ties.
1458
+ refinement.queue.sort((a, b) => a.distance - b.distance)
1459
+ refinement.collecting = false
1460
+ }
1461
+ return
1462
+ }
1463
+ const next = refinement.queue.shift()
1464
+ if (!next) {
1465
+ this.refinementComplete = true
1466
+ return
1093
1467
  }
1468
+ this.currentProposal = next.output
1469
+ this.activeSubSolver = this.createRefinementCandidate(next.output)
1094
1470
  }
1095
1471
 
1096
- getOutput() {
1472
+ getOutput(): InlineOutput {
1097
1473
  if (this.postProcessedOutput) return this.postProcessedOutput
1098
1474
  return this.buildPostProcessedOutput()
1099
1475
  }
@@ -1335,7 +1711,10 @@ const getCollisionLimitedTerminalStubEnd = ({
1335
1711
  if (
1336
1712
  isCollinear &&
1337
1713
  maxAlong >= -GEOMETRY_EPSILON &&
1338
- minAlong <= intendedLength + GEOMETRY_EPSILON
1714
+ minAlong <=
1715
+ intendedLength +
1716
+ INLINE_NET_LABEL_STUB_TRACE_CLEARANCE +
1717
+ GEOMETRY_EPSILON
1339
1718
  ) {
1340
1719
  collisionDistance = Math.max(0, minAlong)
1341
1720
  } else {
@@ -1346,7 +1725,10 @@ const getCollisionLimitedTerminalStubEnd = ({
1346
1725
  crossesStubAxis &&
1347
1726
  Math.abs(startAlong - endAlong) <= GEOMETRY_EPSILON &&
1348
1727
  perpendicularAlong >= -GEOMETRY_EPSILON &&
1349
- perpendicularAlong <= intendedLength + GEOMETRY_EPSILON
1728
+ perpendicularAlong <=
1729
+ intendedLength +
1730
+ INLINE_NET_LABEL_STUB_TRACE_CLEARANCE +
1731
+ GEOMETRY_EPSILON
1350
1732
  ) {
1351
1733
  collisionDistance = Math.max(0, perpendicularAlong)
1352
1734
  }