@tscircuit/schematic-trace-solver 0.0.126 → 0.0.128

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.
package/README.md CHANGED
@@ -35,6 +35,34 @@ Finally, the `NetLabelPlacementSolver` places net labels for each net connection
35
35
  this requires drawing small traces to adapt to the `availableFacingDirections` of the net connection.
36
36
  If there is crowding at the pin, we look for an available spot along the trace connected to the pin.
37
37
 
38
+ ### Inline net labels
39
+
40
+ A point-to-point signal trace can be labeled *inline*: the net name is drawn
41
+ parallel to the wire (above a horizontal trace, to the left of a vertical one)
42
+ instead of as an anchored label hanging off the end of it. This keeps the visual
43
+ line between the two pins intact.
44
+
45
+ Inline labels are opt-in per direct connection - set `allowInlineNetLabel: true`
46
+ on the `directConnection`. The caller (usually [@tscircuit/core](https://github.com/tscircuit/core))
47
+ decides which connections deserve one; the solver only honors the request when
48
+ the connection actually got routed, since there is nothing to run parallel to
49
+ otherwise.
50
+
51
+ The `InlineNetLabelSolver` runs last in the pipeline and exposes
52
+ `inlineNetLabelPlacements`. A net that receives an inline label has its anchored
53
+ `NetLabelPlacement` removed from `getOutput().netLabelPlacements`, so a net is
54
+ never labeled twice.
55
+
56
+ ```tsx
57
+ directConnections: [
58
+ {
59
+ pinIds: ["U1.1", "D1.1"],
60
+ netId: "USER_LED_ANODE",
61
+ allowInlineNetLabel: true,
62
+ },
63
+ ]
64
+ ```
65
+
38
66
  ## Usage
39
67
 
40
68
  ```tsx
package/dist/index.d.ts CHANGED
@@ -88,6 +88,27 @@ interface InputDirectConnection {
88
88
  pinIds: [PinId, PinId];
89
89
  netId?: string;
90
90
  netLabelWidth?: number;
91
+ /**
92
+ * When true, this point-to-point connection may be labeled with an "inline
93
+ * net label": the net name is drawn parallel to (and offset from) the routed
94
+ * trace instead of being placed as a separate anchored net label at the end
95
+ * of the trace.
96
+ *
97
+ * Only set this for connections whose net name is worth showing on the wire -
98
+ * the solver trusts the caller (e.g. @tscircuit/core) to make that decision.
99
+ * An inline label is only emitted when the connection actually got routed.
100
+ */
101
+ allowInlineNetLabel?: boolean;
102
+ /**
103
+ * Extent of the inline net label along the trace. Falls back to
104
+ * `netLabelWidth`, then to an estimate from the netId text.
105
+ */
106
+ inlineNetLabelWidth?: number;
107
+ /**
108
+ * Height of the inline net label text. Defaults to
109
+ * DEFAULT_INLINE_NET_LABEL_HEIGHT.
110
+ */
111
+ inlineNetLabelHeight?: number;
91
112
  }
92
113
  interface InputNetConnection {
93
114
  netId: string;
@@ -1124,6 +1145,97 @@ declare class TraceElbowTransitionSimplificationSolver extends BaseSolver {
1124
1145
  visualize(): GraphicsObject;
1125
1146
  }
1126
1147
 
1148
+ declare const DEFAULT_INLINE_NET_LABEL_HEIGHT = 0.18;
1149
+ /**
1150
+ * Gap between the trace and the near edge of the inline label text.
1151
+ */
1152
+ declare const INLINE_NET_LABEL_TRACE_MARGIN = 0.05;
1153
+ /**
1154
+ * How much of the label is allowed to hang off the end of the wire it names,
1155
+ * as a fraction of the label's length. A short elbow stub is technically clear
1156
+ * of every obstacle but reads as a label floating in space, so runs shorter
1157
+ * than this are not considered.
1158
+ */
1159
+ declare const MIN_INLINE_NET_LABEL_SEGMENT_RATIO = 0.5;
1160
+ /**
1161
+ * A net label drawn parallel to the trace it names, rather than anchored to the
1162
+ * end of it. Used for point-to-point signal traces, where an anchored label
1163
+ * would break the visual line between the two pins.
1164
+ *
1165
+ * `center` is the center of the text box in the schematic's own (unrotated)
1166
+ * coordinate space. `width` is the extent of the text along the trace and
1167
+ * `height` is its extent perpendicular to the trace, so a vertical label has
1168
+ * its `width` running along y.
1169
+ */
1170
+ interface InlineNetLabelPlacement {
1171
+ globalConnNetId: string;
1172
+ netId?: string;
1173
+ mspPairId: string;
1174
+ pinIds: PinId[];
1175
+ /** Axis the text runs along: "x" reads left-to-right, "y" reads bottom-to-top */
1176
+ axis: "x" | "y";
1177
+ /** Midpoint of the trace segment the label is attached to */
1178
+ anchorPoint: Point;
1179
+ /** Center of the label text, offset perpendicular to the trace */
1180
+ center: Point;
1181
+ /** Extent along the trace */
1182
+ width: number;
1183
+ /** Extent perpendicular to the trace */
1184
+ height: number;
1185
+ /**
1186
+ * Which side of the trace the label sits on. For a horizontal trace, "y+" is
1187
+ * above; for a vertical trace, "x-" is the left side (which is "above" once
1188
+ * the text is rotated).
1189
+ */
1190
+ side: "x+" | "x-" | "y+" | "y-";
1191
+ }
1192
+ interface InlineNetLabelSolverInput {
1193
+ inputProblem: InputProblem;
1194
+ traces: SolvedTracePath[];
1195
+ netLabelPlacements: NetLabelPlacement[];
1196
+ }
1197
+ /**
1198
+ * Places "inline net labels" - net names drawn alongside the trace they belong
1199
+ * to - for direct connections that opted in via `allowInlineNetLabel`.
1200
+ *
1201
+ * Any regular (anchored) net label placement for the same net is dropped, so a
1202
+ * net is never labeled twice.
1203
+ */
1204
+ declare class InlineNetLabelSolver extends BaseSolver {
1205
+ inputProblem: InputProblem;
1206
+ traces: SolvedTracePath[];
1207
+ inputNetLabelPlacements: NetLabelPlacement[];
1208
+ inlineNetLabelPlacements: InlineNetLabelPlacement[];
1209
+ /** Direct connections that opted in, still waiting to be processed */
1210
+ queuedDirectConnections: InputDirectConnection[];
1211
+ private tracesByPinPairKey;
1212
+ constructor(input: InlineNetLabelSolverInput);
1213
+ getConstructorParams(): [InlineNetLabelSolverInput];
1214
+ _step(): void;
1215
+ private computeInlinePlacement;
1216
+ /**
1217
+ * An inline label may not sit on top of a chip, a component's text, or a
1218
+ * trace belonging to another net.
1219
+ */
1220
+ private isObstructed;
1221
+ /**
1222
+ * Net label placements superseded by an inline label. A net gets one label or
1223
+ * the other, never both.
1224
+ */
1225
+ private getSupersededNetLabelKeys;
1226
+ getOutput(): {
1227
+ traces: SolvedTracePath[];
1228
+ netLabelPlacements: NetLabelPlacement[];
1229
+ inlineNetLabelPlacements: InlineNetLabelPlacement[];
1230
+ };
1231
+ visualize(): GraphicsObject;
1232
+ }
1233
+ /**
1234
+ * Mirrors the net label text width used by @tscircuit/core so a label that core
1235
+ * did not measure for us still reserves a sane amount of space.
1236
+ */
1237
+ declare const estimateInlineNetLabelWidth: (text: string, fontSize?: number) => number;
1238
+
1127
1239
  /**
1128
1240
  * Pipeline solver that runs a series of solvers to find the best schematic layout.
1129
1241
  * Coordinates the entire layout process from chip partitioning through final packing.
@@ -1162,13 +1274,14 @@ declare class SchematicTracePipelineSolver extends BaseSolver {
1162
1274
  preAlignmentTraceElbowTransitionSimplificationSolver?: TraceElbowTransitionSimplificationSolver;
1163
1275
  finalTraceElbowTransitionSimplificationSolver?: TraceElbowTransitionSimplificationSolver;
1164
1276
  sameNetJunctionAlignmentSolver?: SameNetJunctionAlignmentSolver;
1277
+ inlineNetLabelSolver?: InlineNetLabelSolver;
1165
1278
  startTimeOfPhase: Record<string, number>;
1166
1279
  endTimeOfPhase: Record<string, number>;
1167
1280
  timeSpentOnPhase: Record<string, number>;
1168
1281
  firstIterationOfPhase: Record<string, number>;
1169
1282
  inputProblem: InputProblem;
1170
1283
  hideRatsNet: boolean;
1171
- pipelineDef: (PipelineStep<typeof MspConnectionPairSolver> | PipelineStep<typeof SchematicTraceLinesSolver> | PipelineStep<typeof LongDistancePairSolver> | PipelineStep<typeof UnroutedTraceRecoverySolver> | PipelineStep<typeof TraceOverlapShiftSolver> | PipelineStep<typeof NetLabelPlacementSolver> | PipelineStep<typeof TraceLabelOverlapAvoidanceSolver> | PipelineStep<typeof TraceElbowTransitionSimplificationSolver> | PipelineStep<typeof TraceCleanupSolver> | PipelineStep<typeof Example28Solver> | PipelineStep<typeof AvailableNetOrientationSolver> | PipelineStep<typeof RailNetLabelCornerPlacementSolver> | PipelineStep<typeof TraceAnchoredNetLabelOverlapSolver> | PipelineStep<typeof NetLabelTraceCollisionSolver> | PipelineStep<typeof NetLabelNetLabelCollisionSolver> | PipelineStep<typeof SameNetJunctionAlignmentSolver>)[];
1284
+ pipelineDef: (PipelineStep<typeof MspConnectionPairSolver> | PipelineStep<typeof SchematicTraceLinesSolver> | PipelineStep<typeof LongDistancePairSolver> | PipelineStep<typeof UnroutedTraceRecoverySolver> | PipelineStep<typeof TraceOverlapShiftSolver> | PipelineStep<typeof NetLabelPlacementSolver> | PipelineStep<typeof TraceLabelOverlapAvoidanceSolver> | PipelineStep<typeof TraceElbowTransitionSimplificationSolver> | PipelineStep<typeof TraceCleanupSolver> | PipelineStep<typeof Example28Solver> | PipelineStep<typeof AvailableNetOrientationSolver> | PipelineStep<typeof RailNetLabelCornerPlacementSolver> | PipelineStep<typeof TraceAnchoredNetLabelOverlapSolver> | PipelineStep<typeof NetLabelTraceCollisionSolver> | PipelineStep<typeof NetLabelNetLabelCollisionSolver> | PipelineStep<typeof SameNetJunctionAlignmentSolver> | PipelineStep<typeof InlineNetLabelSolver>)[];
1172
1285
  constructor(inputProblem: InputProblem, opts?: Options);
1173
1286
  getConstructorParams(): ConstructorParameters<typeof SchematicTracePipelineSolver>;
1174
1287
  currentPipelineStepIndex: number;
@@ -1184,4 +1297,4 @@ declare class SchematicTracePipelineSolver extends BaseSolver {
1184
1297
  preview(): GraphicsObject;
1185
1298
  }
1186
1299
 
1187
- export { type ChipId, type InputChip, type InputDirectConnection, type InputNetConnection, type InputPin, type InputProblem, type NetId, type PinId, SchematicTracePipelineSolver, SchematicTraceSingleLineSolver2, type SectionId, type TextBoxes };
1300
+ export { type ChipId, DEFAULT_INLINE_NET_LABEL_HEIGHT, INLINE_NET_LABEL_TRACE_MARGIN, type InlineNetLabelPlacement, InlineNetLabelSolver, type InputChip, type InputDirectConnection, type InputNetConnection, type InputPin, type InputProblem, MIN_INLINE_NET_LABEL_SEGMENT_RATIO, type NetId, type NetLabelPlacement, type PinId, SchematicTracePipelineSolver, SchematicTraceSingleLineSolver2, type SectionId, type TextBoxes, estimateInlineNetLabelWidth };
package/dist/index.js CHANGED
@@ -11863,6 +11863,299 @@ var TraceElbowTransitionSimplificationSolver = class extends BaseSolver {
11863
11863
  }
11864
11864
  };
11865
11865
 
11866
+ // lib/solvers/InlineNetLabelSolver/getAxisAlignedSegments.ts
11867
+ var getAxisAlignedSegments = (path, epsilon = 1e-6) => {
11868
+ const segments = [];
11869
+ let runStart = null;
11870
+ let runAxis = null;
11871
+ let runSign = 0;
11872
+ const closeRun = (runEnd) => {
11873
+ if (runStart && runAxis) {
11874
+ const length = runAxis === "x" ? Math.abs(runEnd.x - runStart.x) : Math.abs(runEnd.y - runStart.y);
11875
+ if (length > epsilon) {
11876
+ segments.push({ start: runStart, end: runEnd, axis: runAxis, length });
11877
+ }
11878
+ }
11879
+ runStart = null;
11880
+ runAxis = null;
11881
+ runSign = 0;
11882
+ };
11883
+ for (let i = 0; i < path.length - 1; i++) {
11884
+ const a = path[i];
11885
+ const b = path[i + 1];
11886
+ const dx = b.x - a.x;
11887
+ const dy = b.y - a.y;
11888
+ if (Math.abs(dx) <= epsilon && Math.abs(dy) <= epsilon) continue;
11889
+ const axis = Math.abs(dy) <= epsilon ? "x" : Math.abs(dx) <= epsilon ? "y" : null;
11890
+ if (!axis) {
11891
+ closeRun(a);
11892
+ continue;
11893
+ }
11894
+ const sign = Math.sign(axis === "x" ? dx : dy);
11895
+ if (runStart && runAxis === axis && runSign === sign) continue;
11896
+ closeRun(a);
11897
+ runStart = a;
11898
+ runAxis = axis;
11899
+ runSign = sign;
11900
+ }
11901
+ if (path.length >= 2) {
11902
+ closeRun(path[path.length - 1]);
11903
+ }
11904
+ return segments.sort((a, b) => b.length - a.length);
11905
+ };
11906
+
11907
+ // lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver.ts
11908
+ var DEFAULT_INLINE_NET_LABEL_HEIGHT = 0.18;
11909
+ var INLINE_NET_LABEL_TRACE_MARGIN = 0.05;
11910
+ var MIN_INLINE_NET_LABEL_SEGMENT_RATIO = 0.5;
11911
+ var getPinPairKey2 = (pinIds) => [...pinIds].sort().join("::");
11912
+ var InlineNetLabelSolver = class extends BaseSolver {
11913
+ inputProblem;
11914
+ traces;
11915
+ inputNetLabelPlacements;
11916
+ inlineNetLabelPlacements = [];
11917
+ /** Direct connections that opted in, still waiting to be processed */
11918
+ queuedDirectConnections;
11919
+ tracesByPinPairKey;
11920
+ constructor(input) {
11921
+ super();
11922
+ this.inputProblem = input.inputProblem;
11923
+ this.traces = input.traces;
11924
+ this.inputNetLabelPlacements = input.netLabelPlacements;
11925
+ this.queuedDirectConnections = this.inputProblem.directConnections.filter(
11926
+ (dc) => dc.allowInlineNetLabel && dc.netId
11927
+ );
11928
+ this.tracesByPinPairKey = /* @__PURE__ */ new Map();
11929
+ for (const trace of this.traces) {
11930
+ const key = getPinPairKey2(trace.pins.map((p) => p.pinId));
11931
+ const existing = this.tracesByPinPairKey.get(key);
11932
+ if (existing) {
11933
+ existing.push(trace);
11934
+ } else {
11935
+ this.tracesByPinPairKey.set(key, [trace]);
11936
+ }
11937
+ }
11938
+ }
11939
+ getConstructorParams() {
11940
+ return [
11941
+ {
11942
+ inputProblem: this.inputProblem,
11943
+ traces: this.traces,
11944
+ netLabelPlacements: this.inputNetLabelPlacements
11945
+ }
11946
+ ];
11947
+ }
11948
+ _step() {
11949
+ const directConnection = this.queuedDirectConnections.shift();
11950
+ if (!directConnection) {
11951
+ this.solved = true;
11952
+ this.stats.inlineNetLabelCount = this.inlineNetLabelPlacements.length;
11953
+ return;
11954
+ }
11955
+ const placement = this.computeInlinePlacement(directConnection);
11956
+ if (placement) {
11957
+ this.inlineNetLabelPlacements.push(placement);
11958
+ }
11959
+ }
11960
+ computeInlinePlacement(directConnection) {
11961
+ const traces = this.tracesByPinPairKey.get(getPinPairKey2(directConnection.pinIds)) ?? [];
11962
+ if (traces.length === 0) return null;
11963
+ const trace = traces[0];
11964
+ const segments = getAxisAlignedSegments(trace.tracePath);
11965
+ if (segments.length === 0) return null;
11966
+ const height = directConnection.inlineNetLabelHeight ?? DEFAULT_INLINE_NET_LABEL_HEIGHT;
11967
+ const width = directConnection.inlineNetLabelWidth ?? directConnection.netLabelWidth ?? estimateInlineNetLabelWidth(directConnection.netId, height);
11968
+ const offset = height / 2 + INLINE_NET_LABEL_TRACE_MARGIN;
11969
+ const usableSegments = segments.filter(
11970
+ (segment) => segment.length >= width * MIN_INLINE_NET_LABEL_SEGMENT_RATIO
11971
+ ).sort((a, b) => {
11972
+ const aFits = a.length >= width;
11973
+ const bFits = b.length >= width;
11974
+ if (aFits !== bFits) return aFits ? -1 : 1;
11975
+ return b.length - a.length;
11976
+ });
11977
+ for (const segment of usableSegments) {
11978
+ const sides = segment.axis === "x" ? ["y+", "y-"] : ["x-", "x+"];
11979
+ for (const side of sides) {
11980
+ for (const anchorPoint of getAnchorCandidates(segment, width)) {
11981
+ const center = side === "y+" ? { x: anchorPoint.x, y: anchorPoint.y + offset } : side === "y-" ? { x: anchorPoint.x, y: anchorPoint.y - offset } : side === "x-" ? { x: anchorPoint.x - offset, y: anchorPoint.y } : { x: anchorPoint.x + offset, y: anchorPoint.y };
11982
+ const halfAlong = width / 2;
11983
+ const halfAcross = height / 2;
11984
+ const bounds = segment.axis === "x" ? {
11985
+ minX: center.x - halfAlong,
11986
+ maxX: center.x + halfAlong,
11987
+ minY: center.y - halfAcross,
11988
+ maxY: center.y + halfAcross
11989
+ } : {
11990
+ minX: center.x - halfAcross,
11991
+ maxX: center.x + halfAcross,
11992
+ minY: center.y - halfAlong,
11993
+ maxY: center.y + halfAlong
11994
+ };
11995
+ if (this.isObstructed(bounds, trace)) continue;
11996
+ return {
11997
+ globalConnNetId: trace.globalConnNetId,
11998
+ netId: directConnection.netId,
11999
+ mspPairId: trace.mspPairId,
12000
+ pinIds: [...directConnection.pinIds],
12001
+ axis: segment.axis,
12002
+ anchorPoint,
12003
+ center,
12004
+ width,
12005
+ height,
12006
+ side
12007
+ };
12008
+ }
12009
+ }
12010
+ }
12011
+ return null;
12012
+ }
12013
+ /**
12014
+ * An inline label may not sit on top of a chip, a component's text, or a
12015
+ * trace belonging to another net.
12016
+ */
12017
+ isObstructed(bounds, ownTrace) {
12018
+ for (const chip of this.inputProblem.chips) {
12019
+ const chipBounds = {
12020
+ minX: chip.center.x - chip.width / 2,
12021
+ maxX: chip.center.x + chip.width / 2,
12022
+ minY: chip.center.y - chip.height / 2,
12023
+ maxY: chip.center.y + chip.height / 2
12024
+ };
12025
+ if (boundsOverlap(bounds, chipBounds)) return true;
12026
+ }
12027
+ for (const textBox of this.inputProblem.textBoxes ?? []) {
12028
+ if (boundsOverlap(bounds, getTextBoxBounds(textBox))) return true;
12029
+ }
12030
+ for (const trace of this.traces) {
12031
+ if (trace.mspPairId === ownTrace.mspPairId) continue;
12032
+ if (trace.globalConnNetId === ownTrace.globalConnNetId) continue;
12033
+ if (doesPathIntersectBounds(trace.tracePath, bounds)) return true;
12034
+ }
12035
+ return false;
12036
+ }
12037
+ /**
12038
+ * Net label placements superseded by an inline label. A net gets one label or
12039
+ * the other, never both.
12040
+ */
12041
+ getSupersededNetLabelKeys() {
12042
+ const keys = /* @__PURE__ */ new Set();
12043
+ for (const placement of this.inlineNetLabelPlacements) {
12044
+ keys.add(placement.globalConnNetId);
12045
+ }
12046
+ return keys;
12047
+ }
12048
+ getOutput() {
12049
+ const superseded = this.getSupersededNetLabelKeys();
12050
+ return {
12051
+ traces: this.traces,
12052
+ netLabelPlacements: this.inputNetLabelPlacements.filter(
12053
+ (placement) => !superseded.has(placement.globalConnNetId)
12054
+ ),
12055
+ inlineNetLabelPlacements: this.inlineNetLabelPlacements
12056
+ };
12057
+ }
12058
+ visualize() {
12059
+ const graphics = visualizeInputProblem(this.inputProblem);
12060
+ graphics.lines ??= [];
12061
+ graphics.rects ??= [];
12062
+ graphics.points ??= [];
12063
+ graphics.texts ??= [];
12064
+ for (const trace of this.traces) {
12065
+ graphics.lines.push({
12066
+ points: trace.tracePath,
12067
+ strokeColor: "purple"
12068
+ });
12069
+ }
12070
+ const { netLabelPlacements } = this.getOutput();
12071
+ for (const label of netLabelPlacements) {
12072
+ graphics.rects.push({
12073
+ center: label.center,
12074
+ width: label.width,
12075
+ height: label.height,
12076
+ fill: getColorFromString(label.globalConnNetId, 0.35),
12077
+ strokeColor: getColorFromString(label.globalConnNetId, 0.9),
12078
+ label: `netId: ${label.netId}
12079
+ globalConnNetId: ${label.globalConnNetId}`
12080
+ });
12081
+ graphics.points.push({
12082
+ x: label.anchorPoint.x,
12083
+ y: label.anchorPoint.y,
12084
+ color: getColorFromString(label.globalConnNetId, 0.9),
12085
+ label: `anchorPoint
12086
+ orientation: ${label.orientation}`
12087
+ });
12088
+ }
12089
+ for (const inlineLabel of this.inlineNetLabelPlacements) {
12090
+ const isHorizontal4 = inlineLabel.axis === "x";
12091
+ graphics.rects.push({
12092
+ center: inlineLabel.center,
12093
+ width: isHorizontal4 ? inlineLabel.width : inlineLabel.height,
12094
+ height: isHorizontal4 ? inlineLabel.height : inlineLabel.width,
12095
+ fill: getColorFromString(inlineLabel.globalConnNetId, 0.35),
12096
+ strokeColor: "green",
12097
+ label: [
12098
+ `INLINE netId: ${inlineLabel.netId}`,
12099
+ `axis: ${inlineLabel.axis}`,
12100
+ `side: ${inlineLabel.side}`
12101
+ ].join("\n")
12102
+ });
12103
+ graphics.texts.push({
12104
+ x: inlineLabel.center.x,
12105
+ y: inlineLabel.center.y,
12106
+ text: inlineLabel.netId ?? "",
12107
+ color: "green",
12108
+ fontSize: inlineLabel.height,
12109
+ anchorSide: "center",
12110
+ // Vertical labels read bottom-to-top, alongside the wire they name.
12111
+ rotation: inlineLabel.axis === "y" ? 90 : void 0
12112
+ });
12113
+ graphics.points.push({
12114
+ x: inlineLabel.anchorPoint.x,
12115
+ y: inlineLabel.anchorPoint.y,
12116
+ color: "green",
12117
+ label: `inline anchor
12118
+ ${inlineLabel.netId}`
12119
+ });
12120
+ }
12121
+ return graphics;
12122
+ }
12123
+ };
12124
+ var estimateInlineNetLabelWidth = (text, fontSize = DEFAULT_INLINE_NET_LABEL_HEIGHT) => {
12125
+ const fontScale = fontSize / 0.18;
12126
+ return text.length * 0.12 * fontScale + 0.12 * fontScale;
12127
+ };
12128
+ var getAnchorCandidates = (segment, labelWidth, step = 0.1) => {
12129
+ const along = segment.axis === "x" ? "x" : "y";
12130
+ const start = segment.start[along];
12131
+ const end = segment.end[along];
12132
+ const mid = (start + end) / 2;
12133
+ const direction = Math.sign(end - start) || 1;
12134
+ const pointAt = (value) => segment.axis === "x" ? { x: value, y: segment.start.y } : { x: segment.start.x, y: segment.start.y + (value - start) };
12135
+ const slack = segment.length - labelWidth;
12136
+ if (slack <= 0) return [pointAt(mid)];
12137
+ const offsets = [0];
12138
+ for (let offset = step; offset <= slack / 2 + 1e-9; offset += step) {
12139
+ offsets.push(offset, -offset);
12140
+ }
12141
+ offsets.push(slack / 2, -slack / 2);
12142
+ return offsets.map((offset) => pointAt(mid + offset * direction));
12143
+ };
12144
+ var doesPathIntersectBounds = (path, bounds) => {
12145
+ for (let i = 0; i < path.length - 1; i++) {
12146
+ const a = path[i];
12147
+ const b = path[i + 1];
12148
+ const segmentBounds = {
12149
+ minX: Math.min(a.x, b.x),
12150
+ maxX: Math.max(a.x, b.x),
12151
+ minY: Math.min(a.y, b.y),
12152
+ maxY: Math.max(a.y, b.y)
12153
+ };
12154
+ if (boundsOverlap(segmentBounds, bounds)) return true;
12155
+ }
12156
+ return false;
12157
+ };
12158
+
11866
12159
  // lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts
11867
12160
  function definePipelineStep(solverName, solverClass, getConstructorParams, opts = {}) {
11868
12161
  return {
@@ -11897,6 +12190,7 @@ var SchematicTracePipelineSolver = class extends BaseSolver {
11897
12190
  preAlignmentTraceElbowTransitionSimplificationSolver;
11898
12191
  finalTraceElbowTransitionSimplificationSolver;
11899
12192
  sameNetJunctionAlignmentSolver;
12193
+ inlineNetLabelSolver;
11900
12194
  startTimeOfPhase;
11901
12195
  endTimeOfPhase;
11902
12196
  timeSpentOnPhase;
@@ -12252,6 +12546,20 @@ var SchematicTracePipelineSolver = class extends BaseSolver {
12252
12546
  }
12253
12547
  ];
12254
12548
  }
12549
+ ),
12550
+ definePipelineStep(
12551
+ "inlineNetLabelSolver",
12552
+ InlineNetLabelSolver,
12553
+ (instance) => {
12554
+ const junctionOutput = instance.sameNetJunctionAlignmentSolver.getOutput();
12555
+ return [
12556
+ {
12557
+ inputProblem: instance.inputProblem,
12558
+ traces: junctionOutput.traces,
12559
+ netLabelPlacements: junctionOutput.netLabelPlacements
12560
+ }
12561
+ ];
12562
+ }
12255
12563
  )
12256
12564
  ];
12257
12565
  constructor(inputProblem, opts) {
@@ -12365,6 +12673,11 @@ var SchematicTracePipelineSolver = class extends BaseSolver {
12365
12673
  }
12366
12674
  };
12367
12675
  export {
12676
+ DEFAULT_INLINE_NET_LABEL_HEIGHT,
12677
+ INLINE_NET_LABEL_TRACE_MARGIN,
12678
+ InlineNetLabelSolver,
12679
+ MIN_INLINE_NET_LABEL_SEGMENT_RATIO,
12368
12680
  SchematicTracePipelineSolver,
12369
- SchematicTraceSingleLineSolver2
12681
+ SchematicTraceSingleLineSolver2,
12682
+ estimateInlineNetLabelWidth
12370
12683
  };
package/lib/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
2
2
  export * from "./types/InputProblem"
3
+ export * from "./solvers/InlineNetLabelSolver/InlineNetLabelSolver"
4
+ export type { NetLabelPlacement } from "./solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
3
5
  export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2"
@@ -0,0 +1,426 @@
1
+ import type { Bounds, Point } from "@tscircuit/math-utils"
2
+ import type { GraphicsObject, Rect } from "graphics-debug"
3
+ import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
4
+ import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
5
+ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
6
+ import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem"
7
+ import type {
8
+ InputDirectConnection,
9
+ InputProblem,
10
+ PinId,
11
+ } from "lib/types/InputProblem"
12
+ import { getColorFromString } from "lib/utils/getColorFromString"
13
+ import { boundsOverlap, getTextBoxBounds } from "lib/utils/textBoxBounds"
14
+ import {
15
+ type AxisAlignedSegment,
16
+ getAxisAlignedSegments,
17
+ } from "./getAxisAlignedSegments"
18
+
19
+ export const DEFAULT_INLINE_NET_LABEL_HEIGHT = 0.18
20
+
21
+ /**
22
+ * Gap between the trace and the near edge of the inline label text.
23
+ */
24
+ export const INLINE_NET_LABEL_TRACE_MARGIN = 0.05
25
+
26
+ /**
27
+ * How much of the label is allowed to hang off the end of the wire it names,
28
+ * as a fraction of the label's length. A short elbow stub is technically clear
29
+ * of every obstacle but reads as a label floating in space, so runs shorter
30
+ * than this are not considered.
31
+ */
32
+ export const MIN_INLINE_NET_LABEL_SEGMENT_RATIO = 0.5
33
+
34
+ /**
35
+ * A net label drawn parallel to the trace it names, rather than anchored to the
36
+ * end of it. Used for point-to-point signal traces, where an anchored label
37
+ * would break the visual line between the two pins.
38
+ *
39
+ * `center` is the center of the text box in the schematic's own (unrotated)
40
+ * coordinate space. `width` is the extent of the text along the trace and
41
+ * `height` is its extent perpendicular to the trace, so a vertical label has
42
+ * its `width` running along y.
43
+ */
44
+ export interface InlineNetLabelPlacement {
45
+ globalConnNetId: string
46
+ netId?: string
47
+ mspPairId: string
48
+ pinIds: PinId[]
49
+
50
+ /** Axis the text runs along: "x" reads left-to-right, "y" reads bottom-to-top */
51
+ axis: "x" | "y"
52
+
53
+ /** Midpoint of the trace segment the label is attached to */
54
+ anchorPoint: Point
55
+
56
+ /** Center of the label text, offset perpendicular to the trace */
57
+ center: Point
58
+
59
+ /** Extent along the trace */
60
+ width: number
61
+ /** Extent perpendicular to the trace */
62
+ height: number
63
+
64
+ /**
65
+ * Which side of the trace the label sits on. For a horizontal trace, "y+" is
66
+ * above; for a vertical trace, "x-" is the left side (which is "above" once
67
+ * the text is rotated).
68
+ */
69
+ side: "x+" | "x-" | "y+" | "y-"
70
+ }
71
+
72
+ interface InlineNetLabelSolverInput {
73
+ inputProblem: InputProblem
74
+ traces: SolvedTracePath[]
75
+ netLabelPlacements: NetLabelPlacement[]
76
+ }
77
+
78
+ const getPinPairKey = (pinIds: readonly string[]) =>
79
+ [...pinIds].sort().join("::")
80
+
81
+ /**
82
+ * Places "inline net labels" - net names drawn alongside the trace they belong
83
+ * to - for direct connections that opted in via `allowInlineNetLabel`.
84
+ *
85
+ * Any regular (anchored) net label placement for the same net is dropped, so a
86
+ * net is never labeled twice.
87
+ */
88
+ export class InlineNetLabelSolver extends BaseSolver {
89
+ inputProblem: InputProblem
90
+ traces: SolvedTracePath[]
91
+ inputNetLabelPlacements: NetLabelPlacement[]
92
+
93
+ inlineNetLabelPlacements: InlineNetLabelPlacement[] = []
94
+
95
+ /** Direct connections that opted in, still waiting to be processed */
96
+ queuedDirectConnections: InputDirectConnection[]
97
+
98
+ private tracesByPinPairKey: Map<string, SolvedTracePath[]>
99
+
100
+ constructor(input: InlineNetLabelSolverInput) {
101
+ super()
102
+ this.inputProblem = input.inputProblem
103
+ this.traces = input.traces
104
+ this.inputNetLabelPlacements = input.netLabelPlacements
105
+
106
+ this.queuedDirectConnections = this.inputProblem.directConnections.filter(
107
+ (dc) => dc.allowInlineNetLabel && dc.netId,
108
+ )
109
+
110
+ this.tracesByPinPairKey = new Map()
111
+ for (const trace of this.traces) {
112
+ const key = getPinPairKey(trace.pins.map((p) => p.pinId))
113
+ const existing = this.tracesByPinPairKey.get(key)
114
+ if (existing) {
115
+ existing.push(trace)
116
+ } else {
117
+ this.tracesByPinPairKey.set(key, [trace])
118
+ }
119
+ }
120
+ }
121
+
122
+ override getConstructorParams(): [InlineNetLabelSolverInput] {
123
+ return [
124
+ {
125
+ inputProblem: this.inputProblem,
126
+ traces: this.traces,
127
+ netLabelPlacements: this.inputNetLabelPlacements,
128
+ },
129
+ ]
130
+ }
131
+
132
+ override _step() {
133
+ const directConnection = this.queuedDirectConnections.shift()
134
+ if (!directConnection) {
135
+ this.solved = true
136
+ this.stats.inlineNetLabelCount = this.inlineNetLabelPlacements.length
137
+ return
138
+ }
139
+
140
+ const placement = this.computeInlinePlacement(directConnection)
141
+ if (placement) {
142
+ this.inlineNetLabelPlacements.push(placement)
143
+ }
144
+ }
145
+
146
+ private computeInlinePlacement(
147
+ directConnection: InputDirectConnection,
148
+ ): InlineNetLabelPlacement | null {
149
+ // Only connections the router actually drew a trace for can carry an inline
150
+ // label - there's nothing to run parallel to otherwise.
151
+ const traces =
152
+ this.tracesByPinPairKey.get(getPinPairKey(directConnection.pinIds)) ?? []
153
+ if (traces.length === 0) return null
154
+
155
+ const trace = traces[0]!
156
+ const segments = getAxisAlignedSegments(trace.tracePath)
157
+ if (segments.length === 0) return null
158
+
159
+ const height =
160
+ directConnection.inlineNetLabelHeight ?? DEFAULT_INLINE_NET_LABEL_HEIGHT
161
+ const width =
162
+ directConnection.inlineNetLabelWidth ??
163
+ directConnection.netLabelWidth ??
164
+ estimateInlineNetLabelWidth(directConnection.netId!, height)
165
+
166
+ const offset = height / 2 + INLINE_NET_LABEL_TRACE_MARGIN
167
+
168
+ // Runs the label fully fits on come first, then longer-to-shorter among the
169
+ // runs it may overhang. Within a run, try the preferred side before the far
170
+ // side, and positions near the middle before ones near the ends.
171
+ const usableSegments = segments
172
+ .filter(
173
+ (segment) =>
174
+ segment.length >= width * MIN_INLINE_NET_LABEL_SEGMENT_RATIO,
175
+ )
176
+ .sort((a, b) => {
177
+ const aFits = a.length >= width
178
+ const bFits = b.length >= width
179
+ if (aFits !== bFits) return aFits ? -1 : 1
180
+ return b.length - a.length
181
+ })
182
+
183
+ for (const segment of usableSegments) {
184
+ // "Above" the trace: +y for a horizontal trace, and -x for a vertical one
185
+ // (rotating the text 90deg counter-clockwise maps "above" onto the left).
186
+ const sides: InlineNetLabelPlacement["side"][] =
187
+ segment.axis === "x" ? ["y+", "y-"] : ["x-", "x+"]
188
+
189
+ for (const side of sides) {
190
+ for (const anchorPoint of getAnchorCandidates(segment, width)) {
191
+ const center =
192
+ side === "y+"
193
+ ? { x: anchorPoint.x, y: anchorPoint.y + offset }
194
+ : side === "y-"
195
+ ? { x: anchorPoint.x, y: anchorPoint.y - offset }
196
+ : side === "x-"
197
+ ? { x: anchorPoint.x - offset, y: anchorPoint.y }
198
+ : { x: anchorPoint.x + offset, y: anchorPoint.y }
199
+
200
+ const halfAlong = width / 2
201
+ const halfAcross = height / 2
202
+ const bounds: Bounds =
203
+ segment.axis === "x"
204
+ ? {
205
+ minX: center.x - halfAlong,
206
+ maxX: center.x + halfAlong,
207
+ minY: center.y - halfAcross,
208
+ maxY: center.y + halfAcross,
209
+ }
210
+ : {
211
+ minX: center.x - halfAcross,
212
+ maxX: center.x + halfAcross,
213
+ minY: center.y - halfAlong,
214
+ maxY: center.y + halfAlong,
215
+ }
216
+
217
+ if (this.isObstructed(bounds, trace)) continue
218
+
219
+ return {
220
+ globalConnNetId: trace.globalConnNetId,
221
+ netId: directConnection.netId,
222
+ mspPairId: trace.mspPairId,
223
+ pinIds: [...directConnection.pinIds],
224
+ axis: segment.axis,
225
+ anchorPoint,
226
+ center,
227
+ width,
228
+ height,
229
+ side,
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ // No room anywhere along the trace. Leave the net to the regular anchored
236
+ // net label rather than drawing the name over a chip.
237
+ return null
238
+ }
239
+
240
+ /**
241
+ * An inline label may not sit on top of a chip, a component's text, or a
242
+ * trace belonging to another net.
243
+ */
244
+ private isObstructed(bounds: Bounds, ownTrace: SolvedTracePath): boolean {
245
+ for (const chip of this.inputProblem.chips) {
246
+ const chipBounds: Bounds = {
247
+ minX: chip.center.x - chip.width / 2,
248
+ maxX: chip.center.x + chip.width / 2,
249
+ minY: chip.center.y - chip.height / 2,
250
+ maxY: chip.center.y + chip.height / 2,
251
+ }
252
+ if (boundsOverlap(bounds, chipBounds)) return true
253
+ }
254
+
255
+ for (const textBox of this.inputProblem.textBoxes ?? []) {
256
+ if (boundsOverlap(bounds, getTextBoxBounds(textBox))) return true
257
+ }
258
+
259
+ for (const trace of this.traces) {
260
+ if (trace.mspPairId === ownTrace.mspPairId) continue
261
+ if (trace.globalConnNetId === ownTrace.globalConnNetId) continue
262
+ if (doesPathIntersectBounds(trace.tracePath, bounds)) return true
263
+ }
264
+
265
+ return false
266
+ }
267
+
268
+ /**
269
+ * Net label placements superseded by an inline label. A net gets one label or
270
+ * the other, never both.
271
+ */
272
+ private getSupersededNetLabelKeys(): Set<string> {
273
+ const keys = new Set<string>()
274
+ for (const placement of this.inlineNetLabelPlacements) {
275
+ keys.add(placement.globalConnNetId)
276
+ }
277
+ return keys
278
+ }
279
+
280
+ getOutput() {
281
+ const superseded = this.getSupersededNetLabelKeys()
282
+ return {
283
+ traces: this.traces,
284
+ netLabelPlacements: this.inputNetLabelPlacements.filter(
285
+ (placement) => !superseded.has(placement.globalConnNetId),
286
+ ),
287
+ inlineNetLabelPlacements: this.inlineNetLabelPlacements,
288
+ }
289
+ }
290
+
291
+ override visualize(): GraphicsObject {
292
+ // Mirrors the previous pipeline stage's visualization so that a problem
293
+ // with no inline labels renders identically, then layers the inline labels
294
+ // on top.
295
+ const graphics = visualizeInputProblem(this.inputProblem)
296
+ graphics.lines ??= []
297
+ graphics.rects ??= []
298
+ graphics.points ??= []
299
+ graphics.texts ??= []
300
+
301
+ for (const trace of this.traces) {
302
+ graphics.lines.push({
303
+ points: trace.tracePath,
304
+ strokeColor: "purple",
305
+ })
306
+ }
307
+
308
+ const { netLabelPlacements } = this.getOutput()
309
+ for (const label of netLabelPlacements) {
310
+ graphics.rects.push({
311
+ center: label.center,
312
+ width: label.width,
313
+ height: label.height,
314
+ fill: getColorFromString(label.globalConnNetId, 0.35),
315
+ strokeColor: getColorFromString(label.globalConnNetId, 0.9),
316
+ label: `netId: ${label.netId}\nglobalConnNetId: ${label.globalConnNetId}`,
317
+ } as Rect & { strokeColor: string })
318
+ graphics.points.push({
319
+ x: label.anchorPoint.x,
320
+ y: label.anchorPoint.y,
321
+ color: getColorFromString(label.globalConnNetId, 0.9),
322
+ label: `anchorPoint\norientation: ${label.orientation}`,
323
+ })
324
+ }
325
+
326
+ for (const inlineLabel of this.inlineNetLabelPlacements) {
327
+ const isHorizontal = inlineLabel.axis === "x"
328
+ graphics.rects.push({
329
+ center: inlineLabel.center,
330
+ width: isHorizontal ? inlineLabel.width : inlineLabel.height,
331
+ height: isHorizontal ? inlineLabel.height : inlineLabel.width,
332
+ fill: getColorFromString(inlineLabel.globalConnNetId, 0.35),
333
+ strokeColor: "green",
334
+ label: [
335
+ `INLINE netId: ${inlineLabel.netId}`,
336
+ `axis: ${inlineLabel.axis}`,
337
+ `side: ${inlineLabel.side}`,
338
+ ].join("\n"),
339
+ } as Rect & { strokeColor: string })
340
+ graphics.texts.push({
341
+ x: inlineLabel.center.x,
342
+ y: inlineLabel.center.y,
343
+ text: inlineLabel.netId ?? "",
344
+ color: "green",
345
+ fontSize: inlineLabel.height,
346
+ anchorSide: "center",
347
+ // Vertical labels read bottom-to-top, alongside the wire they name.
348
+ rotation: inlineLabel.axis === "y" ? 90 : undefined,
349
+ })
350
+ graphics.points.push({
351
+ x: inlineLabel.anchorPoint.x,
352
+ y: inlineLabel.anchorPoint.y,
353
+ color: "green",
354
+ label: `inline anchor\n${inlineLabel.netId}`,
355
+ })
356
+ }
357
+
358
+ return graphics
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Mirrors the net label text width used by @tscircuit/core so a label that core
364
+ * did not measure for us still reserves a sane amount of space.
365
+ */
366
+ export const estimateInlineNetLabelWidth = (
367
+ text: string,
368
+ fontSize = DEFAULT_INLINE_NET_LABEL_HEIGHT,
369
+ ) => {
370
+ const fontScale = fontSize / 0.18
371
+ return text.length * 0.12 * fontScale + 0.12 * fontScale
372
+ }
373
+
374
+ /**
375
+ * Points along a segment where the label's midpoint could sit, ordered from the
376
+ * middle of the segment outwards. Sliding the label along the wire lets it
377
+ * dodge a chip that only crowds one end.
378
+ *
379
+ * When the label is shorter than the segment, candidates are limited to
380
+ * positions that keep it fully on the wire; when it is longer, only the
381
+ * midpoint is offered (it will overhang either way).
382
+ */
383
+ const getAnchorCandidates = (
384
+ segment: AxisAlignedSegment,
385
+ labelWidth: number,
386
+ step = 0.1,
387
+ ): Point[] => {
388
+ const along = segment.axis === "x" ? "x" : "y"
389
+ const start = segment.start[along]
390
+ const end = segment.end[along]
391
+ const mid = (start + end) / 2
392
+ const direction = Math.sign(end - start) || 1
393
+
394
+ const pointAt = (value: number): Point =>
395
+ segment.axis === "x"
396
+ ? { x: value, y: segment.start.y }
397
+ : { x: segment.start.x, y: segment.start.y + (value - start) }
398
+
399
+ const slack = segment.length - labelWidth
400
+ if (slack <= 0) return [pointAt(mid)]
401
+
402
+ const offsets: number[] = [0]
403
+ for (let offset = step; offset <= slack / 2 + 1e-9; offset += step) {
404
+ offsets.push(offset, -offset)
405
+ }
406
+ // Always consider both extremes, even when they fall between samples.
407
+ offsets.push(slack / 2, -slack / 2)
408
+
409
+ return offsets.map((offset) => pointAt(mid + offset * direction))
410
+ }
411
+
412
+ const doesPathIntersectBounds = (path: Point[], bounds: Bounds): boolean => {
413
+ for (let i = 0; i < path.length - 1; i++) {
414
+ const a = path[i]!
415
+ const b = path[i + 1]!
416
+ const segmentBounds: Bounds = {
417
+ minX: Math.min(a.x, b.x),
418
+ maxX: Math.max(a.x, b.x),
419
+ minY: Math.min(a.y, b.y),
420
+ maxY: Math.max(a.y, b.y),
421
+ }
422
+ // Trace segments are axis-aligned, so their bounding box is the segment.
423
+ if (boundsOverlap(segmentBounds, bounds)) return true
424
+ }
425
+ return false
426
+ }
@@ -0,0 +1,73 @@
1
+ import type { Point } from "@tscircuit/math-utils"
2
+
3
+ export interface AxisAlignedSegment {
4
+ start: Point
5
+ end: Point
6
+ axis: "x" | "y"
7
+ length: number
8
+ }
9
+
10
+ /**
11
+ * Splits a trace path into its axis-aligned runs - the straight stretches of
12
+ * wire an inline net label can sit alongside without crossing a corner -
13
+ * ordered longest first.
14
+ *
15
+ * Segments that continue in the same direction are merged, so a straight
16
+ * stretch split into several points counts as one run. A reversal (a trace
17
+ * doubling back on itself) starts a new run, and diagonal segments are dropped
18
+ * since they have no well-defined "parallel" direction.
19
+ */
20
+ export const getAxisAlignedSegments = (
21
+ path: Point[],
22
+ epsilon = 1e-6,
23
+ ): AxisAlignedSegment[] => {
24
+ const segments: AxisAlignedSegment[] = []
25
+
26
+ let runStart: Point | null = null
27
+ let runAxis: "x" | "y" | null = null
28
+ let runSign = 0
29
+
30
+ const closeRun = (runEnd: Point) => {
31
+ if (runStart && runAxis) {
32
+ const length =
33
+ runAxis === "x"
34
+ ? Math.abs(runEnd.x - runStart.x)
35
+ : Math.abs(runEnd.y - runStart.y)
36
+ if (length > epsilon) {
37
+ segments.push({ start: runStart, end: runEnd, axis: runAxis, length })
38
+ }
39
+ }
40
+ runStart = null
41
+ runAxis = null
42
+ runSign = 0
43
+ }
44
+
45
+ for (let i = 0; i < path.length - 1; i++) {
46
+ const a = path[i]!
47
+ const b = path[i + 1]!
48
+ const dx = b.x - a.x
49
+ const dy = b.y - a.y
50
+
51
+ if (Math.abs(dx) <= epsilon && Math.abs(dy) <= epsilon) continue
52
+ const axis: "x" | "y" | null =
53
+ Math.abs(dy) <= epsilon ? "x" : Math.abs(dx) <= epsilon ? "y" : null
54
+ if (!axis) {
55
+ closeRun(a)
56
+ continue
57
+ }
58
+ const sign = Math.sign(axis === "x" ? dx : dy)
59
+
60
+ if (runStart && runAxis === axis && runSign === sign) continue
61
+
62
+ closeRun(a)
63
+ runStart = a
64
+ runAxis = axis
65
+ runSign = sign
66
+ }
67
+
68
+ if (path.length >= 2) {
69
+ closeRun(path[path.length - 1]!)
70
+ }
71
+
72
+ return segments.sort((a, b) => b.length - a.length)
73
+ }
@@ -31,6 +31,7 @@ import { NetLabelNetLabelCollisionSolver } from "../NetLabelNetLabelCollisionSol
31
31
  import { UnroutedTraceRecoverySolver } from "../UnroutedTraceRecoverySolver/UnroutedTraceRecoverySolver"
32
32
  import { SameNetJunctionAlignmentSolver } from "../SameNetJunctionAlignmentSolver/SameNetJunctionAlignmentSolver"
33
33
  import { TraceElbowTransitionSimplificationSolver } from "../TraceElbowTransitionSimplificationSolver/TraceElbowTransitionSimplificationSolver"
34
+ import { InlineNetLabelSolver } from "../InlineNetLabelSolver/InlineNetLabelSolver"
34
35
 
35
36
  type PipelineStep<T extends new (...args: any[]) => BaseSolver> = {
36
37
  solverName: string
@@ -93,6 +94,7 @@ export class SchematicTracePipelineSolver extends BaseSolver {
93
94
  preAlignmentTraceElbowTransitionSimplificationSolver?: TraceElbowTransitionSimplificationSolver
94
95
  finalTraceElbowTransitionSimplificationSolver?: TraceElbowTransitionSimplificationSolver
95
96
  sameNetJunctionAlignmentSolver?: SameNetJunctionAlignmentSolver
97
+ inlineNetLabelSolver?: InlineNetLabelSolver
96
98
 
97
99
  startTimeOfPhase: Record<string, number>
98
100
  endTimeOfPhase: Record<string, number>
@@ -502,6 +504,21 @@ export class SchematicTracePipelineSolver extends BaseSolver {
502
504
  ]
503
505
  },
504
506
  ),
507
+ definePipelineStep(
508
+ "inlineNetLabelSolver",
509
+ InlineNetLabelSolver,
510
+ (instance) => {
511
+ const junctionOutput =
512
+ instance.sameNetJunctionAlignmentSolver!.getOutput()
513
+ return [
514
+ {
515
+ inputProblem: instance.inputProblem,
516
+ traces: junctionOutput.traces,
517
+ netLabelPlacements: junctionOutput.netLabelPlacements,
518
+ },
519
+ ]
520
+ },
521
+ ),
505
522
  ]
506
523
 
507
524
  constructor(inputProblem: InputProblem, opts?: Options) {
@@ -34,6 +34,30 @@ export interface InputDirectConnection {
34
34
  pinIds: [PinId, PinId]
35
35
  netId?: string
36
36
  netLabelWidth?: number
37
+
38
+ /**
39
+ * When true, this point-to-point connection may be labeled with an "inline
40
+ * net label": the net name is drawn parallel to (and offset from) the routed
41
+ * trace instead of being placed as a separate anchored net label at the end
42
+ * of the trace.
43
+ *
44
+ * Only set this for connections whose net name is worth showing on the wire -
45
+ * the solver trusts the caller (e.g. @tscircuit/core) to make that decision.
46
+ * An inline label is only emitted when the connection actually got routed.
47
+ */
48
+ allowInlineNetLabel?: boolean
49
+
50
+ /**
51
+ * Extent of the inline net label along the trace. Falls back to
52
+ * `netLabelWidth`, then to an estimate from the netId text.
53
+ */
54
+ inlineNetLabelWidth?: number
55
+
56
+ /**
57
+ * Height of the inline net label text. Defaults to
58
+ * DEFAULT_INLINE_NET_LABEL_HEIGHT.
59
+ */
60
+ inlineNetLabelHeight?: number
37
61
  }
38
62
 
39
63
  export interface InputNetConnection {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tscircuit/schematic-trace-solver",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.126",
4
+ "version": "0.0.128",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "cosmos",
@@ -19,7 +19,7 @@
19
19
  "calculate-elbow": "^0.0.12",
20
20
  "connectivity-map": "^1.0.0",
21
21
  "flatbush": "^4.5.0",
22
- "graphics-debug": "^0.0.96",
22
+ "graphics-debug": "^0.0.98",
23
23
  "react": "^19.1.1",
24
24
  "react-cosmos": "^7.0.0",
25
25
  "react-cosmos-plugin-vite": "^7.0.0",
@@ -0,0 +1,6 @@
1
+ import { PipelineDebugger } from "site/components/PipelineDebugger"
2
+ import inputProblem from "../../tests/assets/inline-net-label01.json"
3
+
4
+ export { inputProblem }
5
+
6
+ export default () => <PipelineDebugger inputProblem={inputProblem as any} />
@@ -0,0 +1,47 @@
1
+ {
2
+ "chips": [
3
+ {
4
+ "chipId": "U1",
5
+ "center": { "x": 0, "y": 0 },
6
+ "width": 1,
7
+ "height": 0.6,
8
+ "pins": [{ "pinId": "U1.1", "x": 0.5, "y": 0 }]
9
+ },
10
+ {
11
+ "chipId": "U2",
12
+ "center": { "x": 3, "y": 0 },
13
+ "width": 1,
14
+ "height": 0.6,
15
+ "pins": [{ "pinId": "U2.1", "x": 2.5, "y": 0 }]
16
+ },
17
+ {
18
+ "chipId": "U3",
19
+ "center": { "x": 0, "y": -2 },
20
+ "width": 0.6,
21
+ "height": 1,
22
+ "pins": [{ "pinId": "U3.1", "x": 0, "y": -2.5 }]
23
+ },
24
+ {
25
+ "chipId": "U4",
26
+ "center": { "x": 0, "y": -4.5 },
27
+ "width": 0.6,
28
+ "height": 1,
29
+ "pins": [{ "pinId": "U4.1", "x": 0, "y": -4 }]
30
+ }
31
+ ],
32
+ "directConnections": [
33
+ {
34
+ "pinIds": ["U1.1", "U2.1"],
35
+ "netId": "USER_LED_ANODE",
36
+ "allowInlineNetLabel": true
37
+ },
38
+ {
39
+ "pinIds": ["U3.1", "U4.1"],
40
+ "netId": "SPI_SCK",
41
+ "allowInlineNetLabel": true
42
+ }
43
+ ],
44
+ "netConnections": [],
45
+ "availableNetLabelOrientations": {},
46
+ "maxMspPairDistance": 2.4
47
+ }
@@ -0,0 +1,56 @@
1
+ import { expect, test } from "bun:test"
2
+ import { getAxisAlignedSegments } from "lib/solvers/InlineNetLabelSolver/getAxisAlignedSegments"
3
+
4
+ test("getAxisAlignedSegments returns runs longest first", () => {
5
+ const segments = getAxisAlignedSegments([
6
+ { x: 0, y: 0 },
7
+ { x: 1, y: 0 },
8
+ { x: 1, y: 3 },
9
+ { x: 2, y: 3 },
10
+ ])
11
+
12
+ expect(segments.map((s) => [s.axis, s.length])).toEqual([
13
+ ["y", 3],
14
+ ["x", 1],
15
+ ["x", 1],
16
+ ])
17
+ expect(segments[0]).toMatchObject({
18
+ start: { x: 1, y: 0 },
19
+ end: { x: 1, y: 3 },
20
+ })
21
+ })
22
+
23
+ test("getAxisAlignedSegments merges collinear points", () => {
24
+ const segments = getAxisAlignedSegments([
25
+ { x: 0, y: 0 },
26
+ { x: 1, y: 0 },
27
+ { x: 2, y: 0 },
28
+ { x: 2, y: 1 },
29
+ ])
30
+
31
+ expect(segments.map((s) => [s.axis, s.length])).toEqual([
32
+ ["x", 2],
33
+ ["y", 1],
34
+ ])
35
+ })
36
+
37
+ test("getAxisAlignedSegments splits a run that doubles back", () => {
38
+ const segments = getAxisAlignedSegments([
39
+ { x: 0, y: 0 },
40
+ { x: 3, y: 0 },
41
+ { x: 1, y: 0 },
42
+ ])
43
+
44
+ expect(segments.map((s) => s.length)).toEqual([3, 2])
45
+ })
46
+
47
+ test("getAxisAlignedSegments returns nothing for degenerate paths", () => {
48
+ expect(getAxisAlignedSegments([])).toEqual([])
49
+ expect(getAxisAlignedSegments([{ x: 0, y: 0 }])).toEqual([])
50
+ expect(
51
+ getAxisAlignedSegments([
52
+ { x: 0, y: 0 },
53
+ { x: 0, y: 0 },
54
+ ]),
55
+ ).toEqual([])
56
+ })
@@ -0,0 +1,54 @@
1
+ <svg width="640" height="640" viewBox="0 0 640 640" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="white"/><g><polyline data-points="0.5,0 2.5,0" data-type="line" data-label="" points="214.33962264150944,71.69811320754715 425.66037735849056,71.69811320754715" fill="none" stroke="hsl(48, 100%, 50%, 0.8)" stroke-width="1px"/></g><g><polyline data-points="0,-2.5 0,-4" data-type="line" data-label="" points="161.50943396226415,335.84905660377353 161.50943396226415,494.33962264150944" fill="none" stroke="hsl(136, 100%, 50%, 0.8)" stroke-width="1px"/></g><g><polyline data-points="0.5,0 2.5,0" data-type="line" data-label="" points="214.33962264150944,71.69811320754715 425.66037735849056,71.69811320754715" fill="none" stroke="purple" stroke-width="1px"/></g><g><polyline data-points="0,-2.5 0,-4" data-type="line" data-label="" points="161.50943396226415,335.84905660377353 161.50943396226415,494.33962264150944" fill="none" stroke="purple" stroke-width="1px"/></g><g><rect data-type="rect" data-label="U1" data-x="0" data-y="0" x="108.67924528301887" y="39.999999999999986" width="105.66037735849056" height="63.39622641509433" fill="hsl(164, 100%, 50%, 0.8)" stroke="black" stroke-width="0.009464285714285715"/></g><g><rect data-type="rect" data-label="U2" data-x="3" data-y="0" x="425.66037735849056" y="39.999999999999986" width="105.66037735849056" height="63.39622641509433" fill="hsl(165, 100%, 50%, 0.8)" stroke="black" stroke-width="0.009464285714285715"/></g><g><rect data-type="rect" data-label="U3" data-x="0" data-y="-2" x="129.81132075471697" y="230.18867924528303" width="63.39622641509436" height="105.66037735849054" fill="hsl(166, 100%, 50%, 0.8)" stroke="black" stroke-width="0.009464285714285715"/></g><g><rect data-type="rect" data-label="U4" data-x="0" data-y="-4.5" x="129.81132075471697" y="494.3396226415094" width="63.39622641509436" height="105.66037735849056" fill="hsl(167, 100%, 50%, 0.8)" stroke="black" stroke-width="0.009464285714285715"/></g><g><rect data-type="rect" data-label="INLINE netId: USER_LED_ANODE
2
+ axis: x
3
+ side: y+" data-x="1.5" data-y="0.14" x="224.90566037735852" y="47.39622641509432" width="190.18867924528297" height="19.01886792452831" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.009464285714285715"/></g><g><rect data-type="rect" data-label="INLINE netId: SPI_SCK
4
+ axis: y
5
+ side: x-" data-x="-0.14" data-y="-3.25" x="137.20754716981133" y="364.3773584905661" width="19.01886792452828" height="101.43396226415086" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.009464285714285715"/></g><text data-type="text" data-label="USER_LED_ANODE" data-x="1.5" data-y="0.14" x="320" y="56.90566037735847" fill="green" font-size="19.0188679245283" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">USER_LED_ANODE</text><text data-type="text" data-label="SPI_SCK" data-x="-0.14" data-y="-3.25" x="146.7169811320755" y="415.09433962264154" fill="green" font-size="19.0188679245283" font-family="sans-serif" text-anchor="middle" dominant-baseline="central" transform="rotate(-90, 146.7169811320755, 415.09433962264154)">SPI_SCK</text><g><circle data-type="point" data-label="U1.1
6
+ x+" data-x="0.5" data-y="0" cx="214.33962264150944" cy="71.69811320754715" r="3" fill="hsl(319, 100%, 50%, 0.8)"/></g><g><circle data-type="point" data-label="U2.1
7
+ x-" data-x="2.5" data-y="0" cx="425.66037735849056" cy="71.69811320754715" r="3" fill="hsl(200, 100%, 50%, 0.8)"/></g><g><circle data-type="point" data-label="U3.1
8
+ y-" data-x="0" data-y="-2.5" cx="161.50943396226415" cy="335.84905660377353" r="3" fill="hsl(81, 100%, 50%, 0.8)"/></g><g><circle data-type="point" data-label="U4.1
9
+ y+" data-x="0" data-y="-4" cx="161.50943396226415" cy="494.33962264150944" r="3" fill="hsl(322, 100%, 50%, 0.8)"/></g><g><circle data-type="point" data-label="inline anchor
10
+ USER_LED_ANODE" data-x="1.5" data-y="0" cx="320" cy="71.69811320754715" r="3" fill="green"/></g><g><circle data-type="point" data-label="inline anchor
11
+ SPI_SCK" data-x="0" data-y="-3.25" cx="161.50943396226415" cy="415.09433962264154" r="3" fill="green"/></g><g id="crosshair" style="display: none"><line id="crosshair-h" y1="0" y2="640" stroke="#666" stroke-width="0.5"/><line id="crosshair-v" x1="0" x2="640" stroke="#666" stroke-width="0.5"/><text id="coordinates" font-family="monospace" font-size="12" fill="#666"></text></g><script><![CDATA[
12
+ document.currentScript.parentElement.addEventListener('mousemove', (e) => {
13
+ const svg = e.currentTarget;
14
+ const rect = svg.getBoundingClientRect();
15
+ const x = e.clientX - rect.left;
16
+ const y = e.clientY - rect.top;
17
+ const crosshair = svg.getElementById('crosshair');
18
+ const h = svg.getElementById('crosshair-h');
19
+ const v = svg.getElementById('crosshair-v');
20
+ const coords = svg.getElementById('coordinates');
21
+
22
+ crosshair.style.display = 'block';
23
+ h.setAttribute('x1', '0');
24
+ h.setAttribute('x2', '640');
25
+ h.setAttribute('y1', y);
26
+ h.setAttribute('y2', y);
27
+ v.setAttribute('x1', x);
28
+ v.setAttribute('x2', x);
29
+ v.setAttribute('y1', '0');
30
+ v.setAttribute('y2', '640');
31
+
32
+ // Calculate real coordinates using inverse transformation
33
+ const matrix = {"a":105.66037735849056,"c":0,"e":161.50943396226415,"b":0,"d":-105.66037735849056,"f":71.69811320754715};
34
+ // Manually invert and apply the affine transform
35
+ // Since we only use translate and scale, we can directly compute:
36
+ // x' = (x - tx) / sx
37
+ // y' = (y - ty) / sy
38
+ const sx = matrix.a;
39
+ const sy = matrix.d;
40
+ const tx = matrix.e;
41
+ const ty = matrix.f;
42
+ const realPoint = {
43
+ x: (x - tx) / sx,
44
+ y: (y - ty) / sy // Flip y back since we used negative scale
45
+ }
46
+
47
+ coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`;
48
+ coords.setAttribute('x', (x + 5).toString());
49
+ coords.setAttribute('y', (y - 5).toString());
50
+ });
51
+ document.currentScript.parentElement.addEventListener('mouseleave', () => {
52
+ document.currentScript.parentElement.getElementById('crosshair').style.display = 'none';
53
+ });
54
+ ]]></script></svg>
@@ -0,0 +1,44 @@
1
+ import { expect, test } from "bun:test"
2
+ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
3
+ import inputProblem from "../../assets/inline-net-label01.json"
4
+ import "tests/fixtures/matcher"
5
+
6
+ test("inline-net-label01 places inline labels alongside point-to-point traces", () => {
7
+ const solver = new SchematicTracePipelineSolver(inputProblem as any)
8
+
9
+ solver.solve()
10
+
11
+ const inlinePlacements = solver
12
+ .inlineNetLabelSolver!.inlineNetLabelPlacements.slice()
13
+ .sort((a, b) => (a.netId ?? "").localeCompare(b.netId ?? ""))
14
+
15
+ expect(inlinePlacements.map((p) => p.netId)).toEqual([
16
+ "SPI_SCK",
17
+ "USER_LED_ANODE",
18
+ ])
19
+
20
+ const [spiSck, userLedAnode] = inlinePlacements
21
+
22
+ // The horizontal trace gets a horizontal label sitting above the wire.
23
+ expect(userLedAnode!.axis).toBe("x")
24
+ expect(userLedAnode!.side).toBe("y+")
25
+ expect(userLedAnode!.center.x).toBeCloseTo(userLedAnode!.anchorPoint.x, 6)
26
+ expect(userLedAnode!.center.y).toBeGreaterThan(userLedAnode!.anchorPoint.y)
27
+
28
+ // The vertical trace gets a vertical label on the left of the wire, which is
29
+ // "above" it once the text is rotated.
30
+ expect(spiSck!.axis).toBe("y")
31
+ expect(spiSck!.side).toBe("x-")
32
+ expect(spiSck!.center.y).toBeCloseTo(spiSck!.anchorPoint.y, 6)
33
+ expect(spiSck!.center.x).toBeLessThan(spiSck!.anchorPoint.x)
34
+
35
+ // An inline label replaces the anchored label for that net, it never adds a
36
+ // second label.
37
+ const inlineNetIds = new Set(inlinePlacements.map((p) => p.globalConnNetId))
38
+ const anchoredNetIds = solver
39
+ .inlineNetLabelSolver!.getOutput()
40
+ .netLabelPlacements.map((p) => p.globalConnNetId)
41
+ expect(anchoredNetIds.filter((id) => inlineNetIds.has(id))).toEqual([])
42
+
43
+ expect(solver).toMatchSolverSnapshot(import.meta.path)
44
+ })