Skip to content

fix: use finite bend regions for outlined flex boards - #4288

Merged
seveibar merged 2 commits into
mainfrom
fix/adopt-finite-flex-bends
Oct 1, 2026
Merged

seveibar merged 2 commits into
mainfrom
fix/adopt-finite-flex-bends

Conversation

@seveibar

@seveibar seveibar commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

With the U-shaped flex board in tscircuit/tscircuit#5277, a bend across the left tail also folds the right resistor because core does not provide an outline to flex-utils. Adopt @tscircuit/flex-utils 0.0.6 and pass the emitted board outline after subtracting the final board center. RL folds 90°, while RR's CAD position and rotation stay flat. PCB records remain unchanged.

Also adopt released circuit-json-to-gltf 0.0.137, which includes finite board-mesh folding from tscircuit/circuit-json-to-gltf#229. The regression uses core's existing toMatch3dSnapshot path: emitted Circuit JSON → GLB export → PoppyGL. The snapshots show actual board textures and resistor models, and the exporter selects the pose from core's emitted CAD fold flags.

The exact issue fixture is tested at the origin and with a translated board against the same flat/folded snapshots. Numeric checks cover CAD position/rotation, unchanged PCB records, and folded-to-flat restoration. Explicit RL/RR silkscreen labels make the expected behavior visible.

Validation: all 6 PCB-flex tests pass, bunx tsc --noEmit passes, and bun run build passes against the released packages. Existing flex snapshots still match. Temporarily restoring exporter 0.0.133 fails the new folded snapshot; 0.0.137 passes it.

Flat reference:

Flat reference

Only the left tail folds:

Left tail folded

Addresses item 1 of tscircuit/tscircuit#5277 and consumes tscircuit/flex-utils#5. Related: #4279 adds reported-error fallback for unsupported bend geometry and CAD mounts inside bend zones.

@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tscircuit-core-benchmarks Ready Ready Preview Oct 1, 2026 9:37pm UTC

Request Review

Comment on lines +16 to +129
test("finite bend folds only its tail before CAD insertion, including a translated board", async () => {
for (const offset of [
{ x: 0, y: 0 },
{ x: 100, y: -70 },
]) {
const render = async (bent: boolean) => {
const { circuit } = getTestFixture()
circuit.add(
<board
material="flex"
layers={2}
thickness={0.12}
solderMaskColor="#cc9b32"
routingDisabled
schematicDisabled
pcbX={offset.x}
pcbY={offset.y}
outline={outline.map((point) => ({
x: point.x + offset.x,
y: point.y + offset.y,
}))}
>
{bent && (
<pcbbend
x1={-20}
y1={20}
x2={-12}
y2={20}
bendAngle={90}
bendRadius={1}
bendSide="left"
/>
)}
<resistor
name="RL"
resistance="1k"
footprint="0402"
pcbX={-16}
pcbY={25}
/>
<resistor
name="RR"
resistance="1k"
footprint="0402"
pcbX={16}
pcbY={25}
/>
<silkscreentext
text="RL"
pcbX={-16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
<silkscreentext
text="RR"
pcbX={16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
</board>,
)
await circuit.renderUntilSettled()
return circuit
}
const flat = await render(false)
const folded = await render(true)
expect(folded.db.pcb_component.list()).toEqual(flat.db.pcb_component.list())
const [left, right] = folded.db.cad_component.list()
const [flatLeft, flatRight] = flat.db.cad_component.list()
expect(left!.position.z).toBeCloseTo(6 - Math.PI / 4, 7)
expect(left!.rotation!.x).toBeCloseTo(90, 7)
expect(left!.position.x).toBeCloseTo(flatLeft!.position.x, 7)
expect(right!.position).toEqual(flatRight!.position)
for (const coordinate of ["x", "y", "z"] as const)
expect(right!.rotation![coordinate]).toBeCloseTo(
flatRight!.rotation![coordinate],
7,
)
const restored = transformCircuitJsonCadComponents(
folded.getCircuitJson(),
{ foldPcbs: false },
).filter((element) => element.type === "cad_component")
for (let i = 0; i < restored.length; i++)
for (const coordinate of ["x", "y", "z"] as const)
expect(restored[i]!.position[coordinate]).toBeCloseTo(
flat.db.cad_component.list()[i]!.position[coordinate],
7,
)

for (const circuit of [flat, folded]) {
await expect(circuit).toMatch3dSnapshot(import.meta.path, {
// The exporter selects the pose from core's emitted CAD fold flags.
gltf: { boardTextureResolution: 1024 },
snapshotSuffix: circuit === flat ? "flat" : "folded",
diffTolerance: 0.001,
poppygl: {
width: 1000,
height: 760,
// Camera points are right-handed glTF (+Y up, mm), following
// getBestCameraPosition's Circuit JSON -> glTF mapping (-X, Z, Y).
camPos: [55 - offset.x, 60, -65 + offset.y],
lookAt: [-offset.x, 2, 9 + offset.y],
up: "y+",
fov: 35,
backgroundColor: "#f2f3f5",
ambient: 0.45,
grid: undefined,
},
})
}
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file contains a single test(...) block that internally loops over multiple offsets (origin and translated board), effectively running two distinct test scenarios inside one test. The rule states that a *.test.ts file may have AT MOST one test(...), and after that the user should split into multiple numbered files (e.g. finite-segment-cad-placement1.test.tsx, finite-segment-cad-placement2.test.tsx). While there is technically only one test() call, the for loop over [{ x: 0, y: 0 }, { x: 100, y: -70 }] bundles two independent scenarios into one test, which defeats the purpose of the rule. Each offset scenario should be extracted into its own numbered test file.

Suggested change
test("finite bend folds only its tail before CAD insertion, including a translated board", async () => {
for (const offset of [
{ x: 0, y: 0 },
{ x: 100, y: -70 },
]) {
const render = async (bent: boolean) => {
const { circuit } = getTestFixture()
circuit.add(
<board
material="flex"
layers={2}
thickness={0.12}
solderMaskColor="#cc9b32"
routingDisabled
schematicDisabled
pcbX={offset.x}
pcbY={offset.y}
outline={outline.map((point) => ({
x: point.x + offset.x,
y: point.y + offset.y,
}))}
>
{bent && (
<pcbbend
x1={-20}
y1={20}
x2={-12}
y2={20}
bendAngle={90}
bendRadius={1}
bendSide="left"
/>
)}
<resistor
name="RL"
resistance="1k"
footprint="0402"
pcbX={-16}
pcbY={25}
/>
<resistor
name="RR"
resistance="1k"
footprint="0402"
pcbX={16}
pcbY={25}
/>
<silkscreentext
text="RL"
pcbX={-16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
<silkscreentext
text="RR"
pcbX={16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
</board>,
)
await circuit.renderUntilSettled()
return circuit
}
const flat = await render(false)
const folded = await render(true)
expect(folded.db.pcb_component.list()).toEqual(flat.db.pcb_component.list())
const [left, right] = folded.db.cad_component.list()
const [flatLeft, flatRight] = flat.db.cad_component.list()
expect(left!.position.z).toBeCloseTo(6 - Math.PI / 4, 7)
expect(left!.rotation!.x).toBeCloseTo(90, 7)
expect(left!.position.x).toBeCloseTo(flatLeft!.position.x, 7)
expect(right!.position).toEqual(flatRight!.position)
for (const coordinate of ["x", "y", "z"] as const)
expect(right!.rotation![coordinate]).toBeCloseTo(
flatRight!.rotation![coordinate],
7,
)
const restored = transformCircuitJsonCadComponents(
folded.getCircuitJson(),
{ foldPcbs: false },
).filter((element) => element.type === "cad_component")
for (let i = 0; i < restored.length; i++)
for (const coordinate of ["x", "y", "z"] as const)
expect(restored[i]!.position[coordinate]).toBeCloseTo(
flat.db.cad_component.list()[i]!.position[coordinate],
7,
)
for (const circuit of [flat, folded]) {
await expect(circuit).toMatch3dSnapshot(import.meta.path, {
// The exporter selects the pose from core's emitted CAD fold flags.
gltf: { boardTextureResolution: 1024 },
snapshotSuffix: circuit === flat ? "flat" : "folded",
diffTolerance: 0.001,
poppygl: {
width: 1000,
height: 760,
// Camera points are right-handed glTF (+Y up, mm), following
// getBestCameraPosition's Circuit JSON -> glTF mapping (-X, Z, Y).
camPos: [55 - offset.x, 60, -65 + offset.y],
lookAt: [-offset.x, 2, 9 + offset.y],
up: "y+",
fov: 35,
backgroundColor: "#f2f3f5",
ambient: 0.45,
grid: undefined,
},
})
}
}
})
test("finite bend folds only its tail before CAD insertion", async () => {
const offset = { x: 0, y: 0 }
const render = async (bent: boolean) => {
const { circuit } = getTestFixture()
circuit.add(
<board
material="flex"
layers={2}
thickness={0.12}
solderMaskColor="#cc9b32"
routingDisabled
schematicDisabled
pcbX={offset.x}
pcbY={offset.y}
outline={outline.map((point) => ({
x: point.x + offset.x,
y: point.y + offset.y,
}))}
>
{bent && (
<pcbbend
x1={-20}
y1={20}
x2={-12}
y2={20}
bendAngle={90}
bendRadius={1}
bendSide="left"
/>
)}
<resistor
name="RL"
resistance="1k"
footprint="0402"
pcbX={-16}
pcbY={25}
/>
<resistor
name="RR"
resistance="1k"
footprint="0402"
pcbX={16}
pcbY={25}
/>
<silkscreentext
text="RL"
pcbX={-16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
<silkscreentext
text="RR"
pcbX={16}
pcbY={27.5}
fontSize={1.6}
anchorAlignment="center"
/>
</board>,
)
await circuit.renderUntilSettled()
return circuit
}
const flat = await render(false)
const folded = await render(true)
expect(folded.db.pcb_component.list()).toEqual(flat.db.pcb_component.list())
const [left, right] = folded.db.cad_component.list()
const [flatLeft, flatRight] = flat.db.cad_component.list()
expect(left!.position.z).toBeCloseTo(6 - Math.PI / 4, 7)
expect(left!.rotation!.x).toBeCloseTo(90, 7)
expect(left!.position.x).toBeCloseTo(flatLeft!.position.x, 7)
expect(right!.position).toEqual(flatRight!.position)
for (const coordinate of ["x", "y", "z"] as const)
expect(right!.rotation![coordinate]).toBeCloseTo(
flatRight!.rotation![coordinate],
7,
)
const restored = transformCircuitJsonCadComponents(
folded.getCircuitJson(),
{ foldPcbs: false },
).filter((element) => element.type === "cad_component")
for (let i = 0; i < restored.length; i++)
for (const coordinate of ["x", "y", "z"] as const)
expect(restored[i]!.position[coordinate]).toBeCloseTo(
flat.db.cad_component.list()[i]!.position[coordinate],
7,
)
for (const circuit of [flat, folded]) {
await expect(circuit).toMatch3dSnapshot(import.meta.path, {
// The exporter selects the pose from core's emitted CAD fold flags.
gltf: { boardTextureResolution: 1024 },
snapshotSuffix: circuit === flat ? "flat" : "folded",
diffTolerance: 0.001,
poppygl: {
width: 1000,
height: 760,
// Camera points are right-handed glTF (+Y up, mm), following
// getBestCameraPosition's Circuit JSON -> glTF mapping (-X, Z, Y).
camPos: [55 - offset.x, 60, -65 + offset.y],
lookAt: [-offset.x, 2, 9 + offset.y],
up: "y+",
fov: 35,
backgroundColor: "#f2f3f5",
ambient: 0.45,
grid: undefined,
},
})
}
})

Spotted by Graphite (based on custom rule: Custom rule)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@tscircuitbot

Copy link
Copy Markdown
Contributor

Thank you for your contribution! 🎉

PR Rating: ⭐⭐
Impact: Minor

Track your contributions and see the leaderboard at: tscircuit Contribution Tracker


This branch was successfully deployed

1 active deployment
Preview — c8deb84d Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants