From 307e4eb0b9e33df62ea6e4ac603e44cad970d7f0 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:10:53 -0500 Subject: [PATCH 1/9] Document standalone solder paste apertures Part of the tested implementation for tscircuit/core#3577. Prepared with Codex AI assistance. --- docs/SOLDER_PASTE.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/SOLDER_PASTE.md diff --git a/docs/SOLDER_PASTE.md b/docs/SOLDER_PASTE.md new file mode 100644 index 000000000..90ed9f042 --- /dev/null +++ b/docs/SOLDER_PASTE.md @@ -0,0 +1,44 @@ +# Standalone solder paste + +`` creates a paste-only aperture without creating copper, a port, +or an electrical connection. It accepts `shape="rect"` with `width`/`height`, +or `shape="circle"` with `radius`, plus PCB position and layer props. Distances +may be numbers in mm or strings such as `"3mm"`. The default layer is `top`. + +Place apertures in a footprint to inherit its component's placement, rotation, +and board side. Rectangular apertures retain their orientation under arbitrary +parent rotations. Standalone apertures can also be placed directly on a board. + +For example, this footprint has nine stencil windows over one continuous +20 mm-diameter copper contact: + +```tsx +import { Fragment } from "react" + + + + {[-5, 0, 5].flatMap((pcbX) => + [-5, 0, 5].map((pcbY) => ( + + + + )), + )} + +``` + +Here `solderPasteMargin={-10}` reduces the circle's automatically generated +paste radius to zero, suppressing that default aperture. The independent +windows do not alter the copper or solder-mask opening. The aperture dimensions +are emitted exactly as specified; the usual SMT pad paste-size reduction does +not apply to ``. + +The output contains `pcb_solder_paste` records with component/group ownership +where applicable and no `pcb_smtpad_id`. Exporters must support independent paste +records to include them in their stencil output. From f7f5f06af5aee49d38afcf266ae4ffc3f1c05448 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:12:40 -0500 Subject: [PATCH 2/9] Implement standalone solder paste rendering Part of the tested implementation for tscircuit/core#3577. Prepared with Codex AI assistance. --- .../primitive-components/SolderPaste.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 lib/components/primitive-components/SolderPaste.ts diff --git a/lib/components/primitive-components/SolderPaste.ts b/lib/components/primitive-components/SolderPaste.ts new file mode 100644 index 000000000..ba967f357 --- /dev/null +++ b/lib/components/primitive-components/SolderPaste.ts @@ -0,0 +1,131 @@ +import { solderPasteProps } from "@tscircuit/props" +import { applyToPoint } from "transformation-matrix" +import { getAxisAlignedSizeFromRotatedRect } from "lib/utils/pcb/get-axis-aligned-size-from-rotated-rect" +import { PrimitiveComponent } from "../base-components/PrimitiveComponent" + +export class SolderPaste extends PrimitiveComponent { + pcb_solder_paste_id: string | null = null + isPcbPrimitive = true + + get config() { + return { componentName: "SolderPaste", zodProps: solderPasteProps } + } + + /** + * Emit paste-only apertures in board-world mm (+X right, +Y top, +Z above; + * right-handed). Positions are points transformed from the footprint-local + * frame; the rectangle's width direction is transformed without translation. + */ + doInitialPcbPrimitiveRender(): void { + if (this.root?.pcbDisabled) return + const { db } = this.root! + const { _parsedProps: props } = this + const transform = this._computePcbGlobalTransformBeforeLayout() + const position = applyToPoint(transform, { x: 0, y: 0 }) + const { maybeFlipLayer } = this._getPcbPrimitiveFlippedHelpers() + const layer = maybeFlipLayer(props.layer ?? "top") + if (layer !== "top" && layer !== "bottom") { + throw new Error( + `SolderPaste requires a top or bottom layer, got "${layer}"`, + ) + } + + const common = { + x: position.x, + y: position.y, + layer, + pcb_component_id: + this.parent?.pcb_component_id ?? + this.getPrimitiveContainer()?.pcb_component_id ?? + undefined, + subcircuit_id: this.getSubcircuit()?.subcircuit_id ?? undefined, + pcb_group_id: this.getGroup()?.pcb_group_id ?? undefined, + } + + if (props.shape === "circle") { + this.pcb_solder_paste_id = db.pcb_solder_paste.insert({ + ...common, + shape: "circle", + radius: props.radius, + }).pcb_solder_paste_id + return + } + + // Use the same composed transform as the aperture's position, including + // PrimitiveComponent's footprint flip. A rectangle repeats every 180°. + const widthEndpoint = applyToPoint(transform, { x: 1, y: 0 }) + const angle = + (Math.atan2(widthEndpoint.y - position.y, widthEndpoint.x - position.x) * + 180) / + Math.PI + const rotation = ((angle % 180) + 180) % 180 + const isHorizontal = rotation < 1e-8 || Math.abs(rotation - 180) < 1e-8 + const isVertical = Math.abs(rotation - 90) < 1e-8 + + this.pcb_solder_paste_id = db.pcb_solder_paste.insert( + isHorizontal || isVertical + ? { + ...common, + shape: "rect", + width: isVertical ? props.height : props.width, + height: isVertical ? props.width : props.height, + } + : { + ...common, + shape: "rotated_rect", + width: props.width, + height: props.height, + ccw_rotation: rotation, + }, + ).pcb_solder_paste_id + } + + getPcbSize(): { width: number; height: number } { + const props = this._parsedProps + return props.shape === "circle" + ? { width: props.radius * 2, height: props.radius * 2 } + : { width: props.width, height: props.height } + } + + /** Axis-aligned bounds of emitted board-world geometry, in mm. */ + _getPcbCircuitJsonBounds() { + if (!this.pcb_solder_paste_id) return super._getPcbCircuitJsonBounds() + const paste = this.root!.db.pcb_solder_paste.get(this.pcb_solder_paste_id)! + const { width, height } = + paste.shape === "circle" + ? { width: paste.radius * 2, height: paste.radius * 2 } + : getAxisAlignedSizeFromRotatedRect({ + width: paste.width, + height: paste.height, + ccwRotationDegrees: + paste.shape === "rotated_rect" ? paste.ccw_rotation : 0, + }) + return { + center: { x: paste.x, y: paste.y }, + width, + height, + bounds: { + left: paste.x - width / 2, + right: paste.x + width / 2, + top: paste.y + height / 2, + bottom: paste.y - height / 2, + }, + } + } + + /** Move a board-world point in mm after PCB layout. */ + _setPositionFromLayout(newCenter: { x: number; y: number }) { + if (this.root?.pcbDisabled || !this.pcb_solder_paste_id) return + this.root!.db.pcb_solder_paste.update(this.pcb_solder_paste_id, newCenter) + } + + /** Translate emitted board-world geometry by a direction in mm. */ + _moveCircuitJsonElements({ + deltaX, + deltaY, + }: { deltaX: number; deltaY: number }) { + if (this.root?.pcbDisabled || !this.pcb_solder_paste_id) return + const paste = this.root!.db.pcb_solder_paste.get(this.pcb_solder_paste_id)! + this._setPositionFromLayout({ x: paste.x + deltaX, y: paste.y + deltaY }) + } +} From 1217bca9079910b4859aa2227748a356e95ca777 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:15:45 -0500 Subject: [PATCH 3/9] Export the standalone solder paste component --- lib/components/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/components/index.ts b/lib/components/index.ts index b89a2d2e2..068a3439f 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -57,6 +57,7 @@ export { SilkscreenText } from "./primitive-components/SilkscreenText" export { SilkscreenLine } from "./primitive-components/SilkscreenLine" export { SilkscreenGraphic } from "./primitive-components/SilkscreenGraphic" export { SmtPad } from "./primitive-components/SmtPad" +export { SolderPaste } from "./primitive-components/SolderPaste" export { Fiducial } from "./primitive-components/Fiducial" export { Trace } from "./primitive-components/Trace/Trace" export { Bus } from "./primitive-components/Bus" From 57beaa76c6ac75de5076819bd9570c67ad6da3ca Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:16:53 -0500 Subject: [PATCH 4/9] Register solderpaste JSX props --- lib/fiber/intrinsic-jsx.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/fiber/intrinsic-jsx.ts b/lib/fiber/intrinsic-jsx.ts index 5120f39ee..19dc26daa 100644 --- a/lib/fiber/intrinsic-jsx.ts +++ b/lib/fiber/intrinsic-jsx.ts @@ -38,6 +38,7 @@ export interface TscircuitElements { schematicrow: Props.SchematicRowProps schematiccell: Props.SchematicCellProps smtpad: Props.SmtPadProps + solderpaste: Props.SolderPasteProps platedhole: Props.PlatedHoleProps keepout: Props.PcbKeepoutProps hole: Props.HoleProps From 2915adb52004755f9ae08e1a757999ca18e445b3 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:17:54 -0500 Subject: [PATCH 5/9] Test standalone paste and PCB-disabled rendering --- .../solderpaste-standalone.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/components/primitive-components/solderpaste-standalone.test.tsx diff --git a/tests/components/primitive-components/solderpaste-standalone.test.tsx b/tests/components/primitive-components/solderpaste-standalone.test.tsx new file mode 100644 index 000000000..61484e768 --- /dev/null +++ b/tests/components/primitive-components/solderpaste-standalone.test.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { pcb_solder_paste } from "circuit-json" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("standalone paste needs no copper pad and respects PCB-disabled rendering", async () => { + for (const pcbDisabled of [false, true]) { + const { circuit } = getTestFixture() + circuit.pcbDisabled = pcbDisabled + circuit.add( + + + + + , + ) + circuit.render() + const paste = circuit.db.pcb_solder_paste.list() + expect(paste).toHaveLength(pcbDisabled ? 0 : 2) + expect(circuit.db.pcb_smtpad.list()).toHaveLength(0) + expect(circuit.db.pcb_port.list()).toHaveLength(0) + for (const aperture of paste) { + expect(pcb_solder_paste.safeParse(aperture).success).toBe(true) + expect(aperture.pcb_smtpad_id).toBeUndefined() + expect(aperture.pcb_component_id).toBeUndefined() + } + if (!pcbDisabled) { + expect(paste[0]).toMatchObject({ + shape: "rect", + width: 4, + height: 2, + x: -3, + y: 0, + layer: "top", + }) + expect(paste[1]).toMatchObject({ + shape: "circle", + radius: 1, + x: 3, + y: 0, + layer: "bottom", + }) + await expect(circuit).toMatchPcbSnapshot(import.meta.path, { + showSolderPaste: true, + }) + } + } +}) From 9189776c202b5ac112e5ede233f127d801227f36 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:19:01 -0500 Subject: [PATCH 6/9] Test paste geometry against emitted pads on both board sides --- .../solderpaste-transforms.test.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/components/primitive-components/solderpaste-transforms.test.tsx diff --git a/tests/components/primitive-components/solderpaste-transforms.test.tsx b/tests/components/primitive-components/solderpaste-transforms.test.tsx new file mode 100644 index 000000000..a9f68779f --- /dev/null +++ b/tests/components/primitive-components/solderpaste-transforms.test.tsx @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test" +import { SolderPaste } from "lib/components/primitive-components/SolderPaste" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("paste apertures follow emitted footprint geometry on both board sides", () => { + for (const layer of ["top", "bottom"] as const) { + for (const pcbRotation of [0, 90, 180, 270, 37]) { + const { circuit } = getTestFixture() + circuit.add( + + + + + + + + } + /> + , + ) + circuit.render() + const polygon = circuit.db.pcb_smtpad + .list() + .find((pad) => pad.shape === "polygon")! + const copperCircle = circuit.db.pcb_smtpad + .list() + .find((pad) => pad.shape === "circle")! + const apertures = circuit.db.pcb_solder_paste.list() + expect(apertures).toHaveLength(2) + const rect = apertures.find((paste) => paste.shape !== "circle")! + const circle = apertures.find((paste) => paste.shape === "circle")! + expect(rect.layer).toBe(layer) + expect(circle).toMatchObject({ + x: copperCircle.x, + y: copperCircle.y, + radius: 0.5, + layer, + }) + if (rect.shape !== "rect" && rect.shape !== "rotated_rect") + throw new Error("Expected rectangular paste") + if (polygon.shape !== "polygon") + throw new Error("Expected polygon copper") + + // Compare the emitted aperture perimeter to independently emitted copper + // vertices, so a wrong mirror or rotation cannot pass by checking only size. + const angle = + rect.shape === "rotated_rect" ? (rect.ccw_rotation * Math.PI) / 180 : 0 + for (const dx of [-rect.width / 2, rect.width / 2]) { + for (const dy of [-rect.height / 2, rect.height / 2]) { + const x = rect.x + dx * Math.cos(angle) - dy * Math.sin(angle) + const y = rect.y + dx * Math.sin(angle) + dy * Math.cos(angle) + expect( + polygon.points.some( + (point) => Math.hypot(point.x - x, point.y - y) < 1e-8, + ), + ).toBe(true) + } + } + const pastePrimitive = circuit.selectAll("solderpaste")[0] as SolderPaste + const bounds = pastePrimitive._getPcbCircuitJsonBounds() + expect(bounds.bounds.left).toBeCloseTo( + Math.min(...polygon.points.map((p) => p.x)), + ) + expect(bounds.bounds.right).toBeCloseTo( + Math.max(...polygon.points.map((p) => p.x)), + ) + expect(bounds.bounds.top).toBeCloseTo( + Math.max(...polygon.points.map((p) => p.y)), + ) + expect(bounds.bounds.bottom).toBeCloseTo( + Math.min(...polygon.points.map((p) => p.y)), + ) + const oldX = rect.x + const oldY = rect.y + pastePrimitive._moveCircuitJsonElements({ deltaX: 4, deltaY: -2 }) + const moved = circuit.db.pcb_solder_paste.get(rect.pcb_solder_paste_id)! + expect(moved.x).toBeCloseTo(oldX + 4) + expect(moved.y).toBeCloseTo(oldY - 2) + expect(circuit.db.pcb_solder_paste.list()).toHaveLength(2) + } + } +}) From c53287221a32001b4d44006b7541e602128e6a54 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:19:45 -0500 Subject: [PATCH 7/9] Test segmented paste windows over one continuous contact --- .../solderpaste-windowpane.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/components/primitive-components/solderpaste-windowpane.test.tsx diff --git a/tests/components/primitive-components/solderpaste-windowpane.test.tsx b/tests/components/primitive-components/solderpaste-windowpane.test.tsx new file mode 100644 index 000000000..845053106 --- /dev/null +++ b/tests/components/primitive-components/solderpaste-windowpane.test.tsx @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test" +import { Fragment } from "react" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("standalone solderpaste creates windowpanes over one continuous copper pad", async () => { + const { circuit } = getTestFixture() + circuit.add( + + + + {[-5, 0, 5].flatMap((pcbX) => + [-5, 0, 5].map((pcbY) => ( + + + + )), + )} + + } + /> + + , + ) + circuit.render() + + const copper = circuit.db.pcb_smtpad.list() + const paste = circuit.db.pcb_solder_paste.list() + expect(copper).toHaveLength(1) + expect(copper[0]).toMatchObject({ shape: "circle", radius: 10 }) + expect(paste).toHaveLength(9) + expect(new Set(paste.map(({ x, y }) => `${x},${y}`))).toEqual( + new Set([-5, 0, 5].flatMap((x) => [-5, 0, 5].map((y) => `${x},${y}`))), + ) + for (const aperture of paste) { + expect(aperture).toMatchObject({ + shape: "rect", + width: 3, + height: 3, + layer: "top", + pcb_component_id: copper[0]!.pcb_component_id, + }) + expect(aperture.pcb_smtpad_id).toBeUndefined() + } + await expect(circuit).toMatchPcbSnapshot(import.meta.path, { + showSolderPaste: true, + }) +}) From 2e4c2014c8fce40d7b8a6463d1bc4c2c880162cc Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:20:41 -0500 Subject: [PATCH 8/9] Add standalone paste visual regression snapshot --- .../__snapshots__/solderpaste-standalone-pcb.snap.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/components/primitive-components/__snapshots__/solderpaste-standalone-pcb.snap.svg diff --git a/tests/components/primitive-components/__snapshots__/solderpaste-standalone-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/solderpaste-standalone-pcb.snap.svg new file mode 100644 index 000000000..e6e502296 --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/solderpaste-standalone-pcb.snap.svg @@ -0,0 +1 @@ +Paste only: top rectangle / bottom circle From 479172185c2f38b699e8c5f96a6088a5e60ecc44 Mon Sep 17 00:00:00 2001 From: dyoung7293 <49883558+dyoung7293@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:22:00 -0500 Subject: [PATCH 9/9] Add segmented paste visual regression snapshot --- .../__snapshots__/solderpaste-windowpane-pcb.snap.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/components/primitive-components/__snapshots__/solderpaste-windowpane-pcb.snap.svg diff --git a/tests/components/primitive-components/__snapshots__/solderpaste-windowpane-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/solderpaste-windowpane-pcb.snap.svg new file mode 100644 index 000000000..c909ac530 --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/solderpaste-windowpane-pcb.snap.svg @@ -0,0 +1 @@ +9 paste apertures / 1 continuous copper pad