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
44 changes: 44 additions & 0 deletions docs/SOLDER_PASTE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Standalone solder paste

`<solderpaste>` 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"

<footprint>
<smtpad shape="circle" radius={10} solderPasteMargin={-10} />
{[-5, 0, 5].flatMap((pcbX) =>
[-5, 0, 5].map((pcbY) => (
<Fragment key={`${pcbX},${pcbY}`}>
<solderpaste
shape="rect"
width={3}
height={3}
pcbX={pcbX}
pcbY={pcbY}
/>
</Fragment>
)),
)}
</footprint>
```

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 `<solderpaste>`.

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.
1 change: 1 addition & 0 deletions lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
131 changes: 131 additions & 0 deletions lib/components/primitive-components/SolderPaste.ts
Original file line number Diff line number Diff line change
@@ -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<typeof solderPasteProps> {
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 })
}
}
1 change: 1 addition & 0 deletions lib/fiber/intrinsic-jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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,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(
<board width={16} height={12}>
<solderpaste shape="rect" width="4mm" height="2mm" pcbX={-3} />
<solderpaste shape="circle" radius="1mm" pcbX={3} layer="bottom" />
<pcbnotetext
text="Paste only: top rectangle / bottom circle"
pcbY={4}
fontSize={0.5}
/>
</board>,
)
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,
})
}
}
})
111 changes: 111 additions & 0 deletions tests/components/primitive-components/solderpaste-transforms.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<board width={50} height={50} routingDisabled>
<chip
name="U1"
layer={layer}
pcbX={7}
pcbY={11}
pcbRotation={pcbRotation}
footprint={
<footprint>
<smtpad
shape="polygon"
pcbX={2}
pcbY={3}
points={[
{ x: -2, y: -1 },
{ x: 2, y: -1 },
{ x: 2, y: 1 },
{ x: -2, y: 1 },
]}
/>
<solderpaste
shape="rect"
width={4}
height={2}
pcbX={2}
pcbY={3}
/>
<smtpad
shape="circle"
radius={0.5}
pcbX={-3}
pcbY={2}
solderPasteMargin={-0.5}
/>
<solderpaste shape="circle" radius={0.5} pcbX={-3} pcbY={2} />
</footprint>
}
/>
</board>,
)
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)
}
}
})
Loading
Loading