Skip to content

Add explicit SSOP body envelope dimensions - #886

Merged
seveibar merged 2 commits into
mainfrom
feat/ssop-body-envelope
Sep 15, 2026
Merged

seveibar merged 2 commits into
mainfrom
feat/ssop-body-envelope

Conversation

@seveibar

@seveibar seveibar commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

SSOP's existing w determines pad-row placement and does not identify the physical molded body. Add optional, positive finite bodywidth, bodyheight, and bodythickness lengths to the SSOP schema and typed builder.

fp.string("ssop28_p0.65mm_w8.93mm_bodywidth5.3mm_bodyheight10.2mm_bodythickness1.85mm")
fp().ssop(28).p(0.65).w(8.93).bodywidth(5.3).bodyheight(10.2).bodythickness(1.85)

bodywidth is physical X and bodyheight physical Y, before footprint rotation, following the board-plane naming proposed in #866. bodythickness is body Z size, excluding board standoff; it is validated metadata for downstream 3D consumers and does not change 2D geometry. Omitted dimensions remain absent.

When a body outline is supplied, use a rectangular courtyard enclosing both body and actual copper with 0.25mm clearance, including offset thermal pads. An omitted axis retains its legacy bounds. Pad geometry, numbering, and silkscreen remain unchanged; existing strings retain their original courtyard. No manufacturer names or model selectors are introduced.

The example describes an MO-150 SSOP body, such as the package compared against JLC C136617. It supplies the mold envelope needed by the merged SSOP model; physical lead dimensions and standoff are separate future spec work. Footprinter3d integration follows after the schema is accepted and released. This branch is independent of the LGA proposal #885 and can merge separately.

Validation: four new tests cover SVG snapshots, typed builder/string parity, units, invalid/missing lengths, independent axes, thickness-only geometry invariance, and offset thermal-pad clearance. Full suite: 568 pass, zero failures. Build passes; typecheck output is identical to main's existing errors.

SSOP body courtyard

CI fix: refresh the QFN32 KiCad comparison snapshot after reviewing the current reference pin-1 silkscreen marker. Add an explicit zero copper-difference assertion; the boolean copper snapshot remains unchanged. The same fix is applied to both body-dimension PRs so they remain independently mergeable.

Comment on lines +1 to +46
import { expect, test } from "bun:test"
import { convertCircuitJsonToPcbSvg } from "circuit-to-svg"
import { fp } from "../src/footprinter"

test("ssop body outline expands the courtyard without changing copper or silkscreen", () => {
const base = "ssop28_p0.65mm_w8.93mm"
const original = fp.string(base).circuitJson()
for (const [name, suffix, minX, minY] of [
[
"nominal",
"_bodywidth5.3mm_bodyheight10.2mm_bodythickness1.85mm",
5.3 / 2 + 0.25,
10.2 / 2 + 0.25,
],
["large-body", "_bodywidth12mm_bodyheight14mm", 6.25, 7.25],
["width-only", "_bodywidth12mm", 6.25, 0],
["height-only", "_bodyheight14mm", 0, 7.25],
] as const) {
const elements = fp.string(base + suffix).circuitJson()
expect(elements.filter((e) => e.type !== "pcb_courtyard_outline")).toEqual(
original.filter((e) => e.type !== "pcb_courtyard_outline"),
)
const courtyard = elements.find((e) => e.type === "pcb_courtyard_outline")!
if (courtyard.type !== "pcb_courtyard_outline")
throw new Error("missing courtyard")
const hx = Math.max(...courtyard.outline.map((p) => p.x))
const hy = Math.max(...courtyard.outline.map((p) => p.y))
expect(hx).toBeGreaterThanOrEqual(minX)
expect(hy).toBeGreaterThanOrEqual(minY)
for (const pad of elements) {
if (pad.type !== "pcb_smtpad" || pad.shape !== "rect") continue
expect(hx + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.x) + pad.width / 2 + 0.25,
)
expect(hy + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.y) + pad.height / 2 + 0.25,
)
}
expect(
convertCircuitJsonToPcbSvg(elements, {
showCourtyards: true,
viewport: { minX: -hx - 1, maxX: hx + 1, minY: -hy - 1, maxY: hy + 1 },
}),
).toMatchSvgSnapshot(import.meta.path, "ssop-body-" + name)
}
})

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 file contains a single test(...) block that internally iterates over four distinct scenarios ('nominal', 'large-body', 'width-only', 'height-only'), each producing its own SVG snapshot assertion. The style guide 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. add1.test.ts, add2.test.ts). While there is technically only one test() call, the loop drives four independent test cases that should each live in their own file. This file should be split into e.g. ssop-body-envelope1.test.ts (nominal), ssop-body-envelope2.test.ts (large-body), ssop-body-envelope3.test.ts (width-only), and ssop-body-envelope4.test.ts (height-only), each with its own test(...) call.

Suggested change
import { expect, test } from "bun:test"
import { convertCircuitJsonToPcbSvg } from "circuit-to-svg"
import { fp } from "../src/footprinter"
test("ssop body outline expands the courtyard without changing copper or silkscreen", () => {
const base = "ssop28_p0.65mm_w8.93mm"
const original = fp.string(base).circuitJson()
for (const [name, suffix, minX, minY] of [
[
"nominal",
"_bodywidth5.3mm_bodyheight10.2mm_bodythickness1.85mm",
5.3 / 2 + 0.25,
10.2 / 2 + 0.25,
],
["large-body", "_bodywidth12mm_bodyheight14mm", 6.25, 7.25],
["width-only", "_bodywidth12mm", 6.25, 0],
["height-only", "_bodyheight14mm", 0, 7.25],
] as const) {
const elements = fp.string(base + suffix).circuitJson()
expect(elements.filter((e) => e.type !== "pcb_courtyard_outline")).toEqual(
original.filter((e) => e.type !== "pcb_courtyard_outline"),
)
const courtyard = elements.find((e) => e.type === "pcb_courtyard_outline")!
if (courtyard.type !== "pcb_courtyard_outline")
throw new Error("missing courtyard")
const hx = Math.max(...courtyard.outline.map((p) => p.x))
const hy = Math.max(...courtyard.outline.map((p) => p.y))
expect(hx).toBeGreaterThanOrEqual(minX)
expect(hy).toBeGreaterThanOrEqual(minY)
for (const pad of elements) {
if (pad.type !== "pcb_smtpad" || pad.shape !== "rect") continue
expect(hx + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.x) + pad.width / 2 + 0.25,
)
expect(hy + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.y) + pad.height / 2 + 0.25,
)
}
expect(
convertCircuitJsonToPcbSvg(elements, {
showCourtyards: true,
viewport: { minX: -hx - 1, maxX: hx + 1, minY: -hy - 1, maxY: hy + 1 },
}),
).toMatchSvgSnapshot(import.meta.path, "ssop-body-" + name)
}
})
import { expect, test } from "bun:test"
import { convertCircuitJsonToPcbSvg } from "circuit-to-svg"
import { fp } from "../src/footprinter"
test("ssop body outline expands the courtyard without changing copper or silkscreen (nominal)", () => {
const base = "ssop28_p0.65mm_w8.93mm"
const original = fp.string(base).circuitJson()
const name = "nominal"
const suffix = "_bodywidth5.3mm_bodyheight10.2mm_bodythickness1.85mm"
const minX = 5.3 / 2 + 0.25
const minY = 10.2 / 2 + 0.25
const elements = fp.string(base + suffix).circuitJson()
expect(elements.filter((e) => e.type !== "pcb_courtyard_outline")).toEqual(
original.filter((e) => e.type !== "pcb_courtyard_outline"),
)
const courtyard = elements.find((e) => e.type === "pcb_courtyard_outline")!
if (courtyard.type !== "pcb_courtyard_outline")
throw new Error("missing courtyard")
const hx = Math.max(...courtyard.outline.map((p) => p.x))
const hy = Math.max(...courtyard.outline.map((p) => p.y))
expect(hx).toBeGreaterThanOrEqual(minX)
expect(hy).toBeGreaterThanOrEqual(minY)
for (const pad of elements) {
if (pad.type !== "pcb_smtpad" || pad.shape !== "rect") continue
expect(hx + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.x) + pad.width / 2 + 0.25,
)
expect(hy + 1e-9).toBeGreaterThanOrEqual(
Math.abs(pad.y) + pad.height / 2 + 0.25,
)
}
expect(
convertCircuitJsonToPcbSvg(elements, {
showCourtyards: true,
viewport: { minX: -hx - 1, maxX: hx + 1, minY: -hy - 1, maxY: hy + 1 },
}),
).toMatchSvgSnapshot(import.meta.path, "ssop-body-" + name)
})

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

Fix in Graphite


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

Comment on lines +1 to +44
import { expect, test } from "bun:test"
import { convertCircuitJsonToPcbSvg } from "circuit-to-svg"
import { fp } from "../src/footprinter"
import { ssop_def } from "../src/fn/ssop"

test("ssop body dimensions are validated schema fields with typed builder and unit parity", () => {
const base = "ssop28_p0.65mm_w8.93mm"
const suffix = "_bodywidth5.3mm_bodyheight10.2mm_bodythickness1.85mm"
const generated = fp.string(base + suffix)
expect(generated.json()).toMatchObject({
bodywidth: 5.3,
bodyheight: 10.2,
bodythickness: 1.85,
})
const builder = fp()
.ssop(28)
.p(0.65)
.w(8.93)
.bodywidth(5.3)
.bodyheight(10.2)
.bodythickness(1.85)
expect(builder.circuitJson()).toEqual(generated.circuitJson())
const parsed = ssop_def.parse({
fn: "ssop",
bodywidth: "100mil",
bodyheight: "0.1in",
bodythickness: "0.1cm",
})
expect(parsed.bodywidth).toBeCloseTo(2.54)
expect(parsed.bodyheight).toBeCloseTo(2.54)
expect(parsed.bodythickness).toBeCloseTo(1)
// Z metadata never moves pads or changes the 2D outline.
expect(fp.string(base + "_bodythickness1mm").circuitJson()).toEqual(
fp.string(base).circuitJson(),
)
for (const key of ["bodywidth", "bodyheight", "bodythickness"] as const) {
expect(ssop_def.parse({ fn: "ssop" })[key]).toBeUndefined()
}
expect(
convertCircuitJsonToPcbSvg(generated.circuitJson(), {
showCourtyards: true,
}),
).toMatchSvgSnapshot(import.meta.path, "ssop-body-parameters")
})

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 file's single test(...) block covers multiple independent concerns: (1) string-based parameter parsing, (2) typed builder parity, (3) unit conversion (mil/in/cm), (4) the Z-metadata invariant (bodythickness does not affect 2D geometry), and (5) an SVG snapshot. The style guide rule states that a *.test.ts file may have AT MOST one test(...), and after that the user should split into multiple, numbered files. Because these are clearly distinct test scenarios bundled into one test, this file should be split into numbered files such as ssop-body-parameters1.test.ts, ssop-body-parameters2.test.ts, etc., each containing a single focused test(...) call.

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

Fix in Graphite


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

@seveibar
seveibar merged commit d7cd7a0 into main Sep 15, 2026
5 checks passed
@seveibar
seveibar deleted the feat/ssop-body-envelope branch September 15, 2026 14:54
@tscircuitbot

Copy link
Copy Markdown
Contributor

Thank you for your contribution! 🎉

PR Rating: ⭐⭐⭐
Impact: Major

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


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