Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Bounds, Point } from "@tscircuit/math-utils"
import type { NetLabelPlacement } from "./NetLabelPlacementSolver"
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import { getAnchoredNetLabelRenderedBounds } from "lib/solvers/InlineNetLabelSolver/getAnchoredNetLabelRenderedBounds"
import { segmentIntersectsRect } from "./SingleNetLabelPlacementSolver/collisions"

export const NET_LABEL_TRACE_CLEARANCE = 0.05
const EPS = 1e-6
const expand = (bounds: Bounds): Bounds => ({
minX: bounds.minX - NET_LABEL_TRACE_CLEARANCE + EPS,
maxX: bounds.maxX + NET_LABEL_TRACE_CLEARANCE - EPS,
minY: bounds.minY - NET_LABEL_TRACE_CLEARANCE + EPS,
maxY: bounds.maxY + NET_LABEL_TRACE_CLEARANCE - EPS,
})

/** Validate the rendered tag and its entire proposed connector as one placement.
* Unlike strict crossing tests, padded bounds reject endpoint contacts, near
* contacts and parallel overlaps with another net. Same-net attachments remain legal.
*/
export const isLabelAndConnectorClearOfTraces = ({
label,
connectorPath,
traces,
}: {
label: NetLabelPlacement
connectorPath: Point[]
traces: SolvedTracePath[]
}): boolean => {
const bounds = [expand(getAnchoredNetLabelRenderedBounds(label))]
for (let index = 0; index < connectorPath.length; index++) {
const a = connectorPath[index]!
const b = connectorPath[index + 1] ?? a
if (Math.abs(a.x - b.x) > EPS && Math.abs(a.y - b.y) > EPS) return false
bounds.push(
expand({
minX: Math.min(a.x, b.x),
maxX: Math.max(a.x, b.x),
minY: Math.min(a.y, b.y),
maxY: Math.max(a.y, b.y),
}),
)
}
return !traces.some(
(trace) =>
trace.globalConnNetId !== label.globalConnNetId &&
trace.tracePath.some((a, index) => {
const b = trace.tracePath[index + 1] ?? a
return bounds.some(
(box) =>
(a.x > box.minX &&
a.x < box.maxX &&
a.y > box.minY &&
a.y < box.maxY) ||
segmentIntersectsRect(a, b, box),
)
}),
)
}
12 changes: 12 additions & 0 deletions site/InlineNetLabelSolver/shaft-position-label-clearance.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useMemo } from "react"
import { GenericSolverDebugger } from "site/components/GenericSolverDebugger"
import { InlineNetLabelSolver } from "lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver"
import { getShaftPositionLabelClearanceInput } from "../../tests/repros/assets/repro-rp2040-shaft-position-label-clearance.input"

export default () => {
const solver = useMemo(
() => new InlineNetLabelSolver(getShaftPositionLabelClearanceInput()),
[],
)
return <GenericSolverDebugger solver={solver} />
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { InputProblem } from "lib/types/InputProblem"
import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver"
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import {
getPinMap,
getTracePins,
} from "lib/solvers/AvailableNetOrientationSolver/traces"
import sourceInput from "./repro-rp2040-shaft-position.input.json"

/** Replay the published SDA wire and SCL tag/stub at the label-clearance stage.
* The upstream pipeline currently chooses different routes (see the repro README).
* Disable new inline conversions so this regression preserves the reported tags.
*/
export const getShaftPositionLabelClearanceInput = () => {
const inputProblem = structuredClone(sourceInput) as unknown as InputProblem
for (const connection of inputProblem.directConnections) {
connection.allowInlineNetLabel = false
}
inputProblem.textBoxes!.push({
text: "ENC_SDA",
center: { x: -8.6425, y: 0.5989999999999993 },
width: 0.64,
height: 0.12,
})
const pinMap = getPinMap(inputProblem)
const label: NetLabelPlacement = {
netId: "ENC_SCL",
netLabelText: "ENC_SCL",
globalConnNetId: "ENC_SCL",
pinIds: ["R_ENC_SCL.1"],
mspConnectionPairIds: [],
orientation: "y-",
anchorPoint: { x: -9, y: 0.5399999999999991 },
center: { x: -9, y: 0.4499999999999992 },
width: 0.96,
height: 0.18,
}
const connectorId = "inline-net-label-clearance-7-R_ENC_SCL_SIGNAL"
const sdaPinIds = ["R_ENC_SDA.1", "U_ENCODER.6"]
const traces: SolvedTracePath[] = [
{
mspPairId: "shaft-position-sda",
mspConnectionPairIds: ["shaft-position-sda"],
dcConnNetId: "ENC_SDA",
globalConnNetId: "ENC_SDA",
userNetId: "ENC_SDA",
pinIds: sdaPinIds,
pins: [pinMap[sdaPinIds[0]!]!, pinMap[sdaPinIds[1]!]!],
tracePath: [
{ x: -11, y: 1.9000000000000001 },
{ x: -11, y: 0.5389999999999993 },
{ x: -6.285, y: 0.5389999999999993 },
],
},
{
mspPairId: connectorId,
mspConnectionPairIds: [connectorId],
dcConnNetId: "ENC_SCL",
globalConnNetId: "ENC_SCL",
userNetId: "ENC_SCL",
pinIds: label.pinIds,
pins: getTracePins(label, pinMap),
tracePath: [{ x: -9, y: 1.9000000000000001 }, { ...label.anchorPoint }],
},
]
return {
inputProblem,
traces,
netLabelPlacements: [label],
netLabelConnectorTraceIds: new Set([connectorId]),
}
}
33 changes: 33 additions & 0 deletions tests/repros/repro-rp2040-shaft-position-label-clearance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import { InlineNetLabelSolver } from "lib/solvers/InlineNetLabelSolver/InlineNetLabelSolver"
import { getAnchoredNetLabelRenderedBounds } from "lib/solvers/InlineNetLabelSolver/getAnchoredNetLabelRenderedBounds"
import { getShaftPositionLabelClearanceInput } from "./assets/repro-rp2040-shaft-position-label-clearance.input"
import "tests/fixtures/matcher"

test("shaft-position SCL tag and stub stay above the unrelated SDA wire", async () => {
const input = getShaftPositionLabelClearanceInput()
const before = structuredClone(input)
const solver = new InlineNetLabelSolver(input)
solver.solve()
const output = solver.getOutput()
const scl = output.netLabelPlacements.find(
(label) => label.netId === "ENC_SCL",
)!
expect(scl.orientation).toBe("x-")
const sdaY = input.traces[0]!.tracePath[1]!.y
expect(solver.solved).toBe(true)
expect(getAnchoredNetLabelRenderedBounds(scl).minY).toBeGreaterThan(
sdaY + 0.1,
)
const stub = output.traces.find((trace) =>
input.netLabelConnectorTraceIds.has(trace.mspPairId),
)!
expect(stub.tracePath[0]).toEqual(input.traces[1]!.tracePath[0])
expect(stub.tracePath.at(-1)).toEqual(scl.anchorPoint)
expect(Math.min(...stub.tracePath.map((point) => point.y))).toBeGreaterThan(
sdaY + 0.1,
)
expect(output.traces[0]).toEqual(input.traces[0])
expect(input).toEqual(before)
await expect(solver).toMatchSolverSnapshot(import.meta.path)
})
37 changes: 37 additions & 0 deletions tests/repros/repro-rp2040-shaft-position.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,40 @@ For interactive investigation, run `bun start` and open
`SchematicTracePipelineSolver/repro-rp2040-shaft-position` in Cosmos.

![Current solver snapshot](__snapshots__/repro-rp2040-shaft-position.snap.svg)

## Label-placement fix (stacked on the original repro)

The existing anchored-label clearance pass now treats the rendered tag and
proposed connector as one candidate. It rejects endpoint contacts, near contacts,
parallel overlaps and crossings with unrelated nets using 0.05 clearance.
Same-net attachment is still allowed. A proposal is committed only after all
labels and connectors in the group pass validation, including against one another.

The usual outward proposal remains first. If it fails for an individual label,
the same placement search tries alternate anchors and permitted orientations,
ordered by connector length. Chip/text/label obstacles, connector attachments,
existing same-net branches and group alignment remain constraints. A rejected
proposal changes neither labels nor traces. No cleanup/shortening stage is added.

`anchored-label-candidate-clearance.test.ts` starts with a tag at its resistor
pin, before any connector exists. An inline-text obstacle makes the outward
proposal unsafe. This test fails on the parent implementation; the candidate
search instead chooses a horizontal label on a 0.1 connector near the resistor.
Additional cases cover complete blockage, orientation constraints, endpoint and
near contacts, parallel overlap, and legitimate same-net attachment.

`repro-rp2040-shaft-position-label-clearance.test.ts` also replays the published
SDA trace and SCL label/connector at the clearance stage. Fresh inline conversions
are disabled and existing SDA text is a fixed obstacle. It confirms that the
search can reconsider the ambiguous retained placement without changing SDA.
The SCL tag now faces left beside the resistor, above the SDA wire. This remains
a stage replay, not a captured historical full-pipeline invocation.

```sh
bun test tests/repros/repro-rp2040-shaft-position-label-clearance.test.ts
bun test tests/solvers/InlineNetLabelSolver/anchored-label-candidate-clearance.test.ts
```

Open `InlineNetLabelSolver/shaft-position-label-clearance` in Cosmos for the replay.

![SCL label selected above SDA](__snapshots__/repro-rp2040-shaft-position-label-clearance.snap.svg)
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { expect, test } from "bun:test"
import { pushAnchoredNetLabelsAwayFromInlineLabels } from "lib/solvers/InlineNetLabelSolver/pushAnchoredNetLabelsAwayFromInlineLabels"
import { isLabelAndConnectorClearOfTraces } from "lib/solvers/NetLabelPlacementSolver/isLabelAndConnectorClearOfTraces"
import { getShaftPositionLabelClearanceInput } from "tests/repros/assets/repro-rp2040-shaft-position-label-clearance.input"

const getCandidateInput = () => {
const input = getShaftPositionLabelClearanceInput()
// Start at the resistor pin, before a clearance connector is created. Inline
// text conflicts with the lower end of the initial vertical tag. An outward
// push would put the tag through the foreign SDA wire; a horizontal tag fits.
input.traces.pop()
input.netLabelConnectorTraceIds.clear()
input.netLabelPlacements[0]!.anchorPoint = { x: -9, y: 1.9 }
input.netLabelPlacements[0]!.center = { x: -9, y: 1.81 }
return {
...input,
inlineNetLabelPlacements: [
{
globalConnNetId: "ENC_SDA",
netId: "ENC_SDA",
pinIds: ["R_ENC_SDA.1", "U_ENCODER.6"],
axis: "x" as const,
side: "y+" as const,
anchorPoint: { x: -9, y: 0.93 },
center: { x: -9, y: 1.02 },
width: 0.8,
height: 0.18,
},
],
}
}

test("chooses a valid label and connector before committing an outward push", () => {
const input = getCandidateInput()
const original = structuredClone(input)
const output = pushAnchoredNetLabelsAwayFromInlineLabels(input)
const label = output.netLabelPlacements[0]!
const connector = output.traces.find((trace) =>
output.netLabelConnectorTraceIds.has(trace.mspPairId),
)!
expect(output.movedLabelCount).toBe(1)
expect(["x-", "x+"]).toContain(label.orientation)
expect(label.anchorPoint.y).toBeCloseTo(1.8)
expect(connector.tracePath).toEqual([
input.netLabelPlacements[0]!.anchorPoint,
label.anchorPoint,
])
expect(
isLabelAndConnectorClearOfTraces({
label,
connectorPath: connector.tracePath,
traces: input.traces,
}),
).toBe(true)
expect(output.traces[0]).toEqual(input.traces[0])
expect(input).toEqual(original)
})

test("rejects the entire proposal if every allowed candidate is blocked", () => {
const input = getCandidateInput()
input.inputProblem.textBoxes!.push({
center: { x: -9, y: -0.5 },
width: 12,
height: 6,
})
const output = pushAnchoredNetLabelsAwayFromInlineLabels(input)
expect(output.movedLabelCount).toBe(0)
expect(output.traces).toEqual(input.traces)
expect(output.netLabelPlacements).toEqual(input.netLabelPlacements)
expect(output.netLabelConnectorTraceIds.size).toBe(0)
})

for (const gap of [0, 0.001, -0.2]) {
test(`candidate validation rejects foreign-wire contact at gap ${gap}`, () => {
const input = getShaftPositionLabelClearanceInput()
const label = input.netLabelPlacements[0]!
label.anchorPoint.y = input.traces[0]!.tracePath[1]!.y + gap
label.center.y = label.anchorPoint.y - 0.09
expect(
isLabelAndConnectorClearOfTraces({
label,
connectorPath: [{ x: -9, y: 1.9 }, label.anchorPoint],
traces: [input.traces[0]!],
}),
).toBe(false)
})
}

test("same-net attachment remains legal", () => {
const input = getShaftPositionLabelClearanceInput()
input.traces[0]!.globalConnNetId = "ENC_SCL"
expect(
isLabelAndConnectorClearOfTraces({
label: input.netLabelPlacements[0]!,
connectorPath: input.traces[1]!.tracePath,
traces: [input.traces[0]!],
}),
).toBe(true)
})

test("alternate placement respects an explicit orientation constraint", () => {
const input = getCandidateInput()
input.inputProblem.availableNetLabelOrientations.ENC_SCL = ["x+"]
const output = pushAnchoredNetLabelsAwayFromInlineLabels(input)
expect(output.movedLabelCount).toBe(1)
expect(output.netLabelPlacements[0]!.orientation).toBe("x+")
})

test("rejects parallel connector overlap with another net", () => {
const input = getShaftPositionLabelClearanceInput()
input.traces[0]!.tracePath = [
{ x: -9.001, y: 0.6 },
{ x: -9.001, y: 1.8 },
]
expect(
isLabelAndConnectorClearOfTraces({
label: input.netLabelPlacements[0]!,
connectorPath: input.traces[1]!.tracePath,
traces: [input.traces[0]!],
}),
).toBe(false)
})

test("horizontal and reversed connectors obey the same clearance rule", () => {
const input = getShaftPositionLabelClearanceInput()
const rotate = (point: { x: number; y: number }) => ({
x: -point.y,
y: point.x,
})
const label = input.netLabelPlacements[0]!
label.anchorPoint = rotate(label.anchorPoint)
label.orientation = "x+"
label.center = {
x: label.anchorPoint.x + label.width / 2,
y: label.anchorPoint.y,
}
input.traces[0]!.tracePath = input.traces[0]!.tracePath.map(rotate)
const connectorPath = input.traces[1]!.tracePath.map(rotate).reverse()
expect(
isLabelAndConnectorClearOfTraces({
label,
connectorPath,
traces: [input.traces[0]!],
}),
).toBe(false)
})
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ test("moves a contiguous label row together and shoves an anchored obstacle", ()
{ pinId: "U1.minus", x: -0.7, y: 0.4, _facingDirection: "x-" },
{ pinId: "U1.plus", x: -0.7, y: 0.2, _facingDirection: "x-" },
{ pinId: "U1.inline", x: -0.7, y: 0, _facingDirection: "x-" },
{ pinId: "U1.gnd1", x: -0.7, y: 0.5, _facingDirection: "x-" },
{ pinId: "U1.gnd2", x: -2.5, y: 0.5, _facingDirection: "x+" },
{ pinId: "U1.gnd1", x: -0.7, y: 0.6, _facingDirection: "x-" },
{ pinId: "U1.gnd2", x: -2.5, y: 0.6, _facingDirection: "x+" },
],
},
],
Expand Down Expand Up @@ -239,8 +239,8 @@ test("moves a contiguous label row together and shoves an anchored obstacle", ()
mspConnectionPairIds: ["gnd-route"],
pinIds: ["U1.gnd1", "U1.gnd2"],
orientation: "y-",
anchorPoint: { x: -2.5, y: 0.5 },
center: { x: -2.5, y: 0.3 },
anchorPoint: { x: -2.5, y: 0.6 },
center: { x: -2.5, y: 0.4 },
width: 0.4,
height: 0.4,
},
Expand All @@ -252,8 +252,8 @@ test("moves a contiguous label row together and shoves an anchored obstacle", ()
globalConnNetId: "gnd-net",
pins: [] as any,
tracePath: [
{ x: -0.7, y: 0.5 },
{ x: -2.5, y: 0.5 },
{ x: -0.7, y: 0.6 },
{ x: -2.5, y: 0.6 },
],
mspConnectionPairIds: ["gnd-route"],
pinIds: ["U1.gnd1", "U1.gnd2"],
Expand Down
Loading