From a8756ac0cf2cc4e4487be6e87b48773612ea9dcf Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 22:10:18 +0900 Subject: [PATCH] fix: draw the sot23 body outline and pin 1 marker (#732) --- src/fn/sot23.ts | 90 ++++++++++++++++++- tests/__snapshots__/sot23.snap.svg | 2 +- tests/__snapshots__/sot23_3.snap.svg | 2 +- .../sot23_3_micrometer_units.snap.svg | 2 +- .../sot23_w3_h1.5_p0.95mm.snap.svg | 2 +- .../kicad-parity/__snapshots__/sot23.snap.svg | 2 +- tests/sot23.test.ts | 63 +++++++++++++ 7 files changed, 157 insertions(+), 6 deletions(-) diff --git a/src/fn/sot23.ts b/src/fn/sot23.ts index de1507343..1c7dafb84 100644 --- a/src/fn/sot23.ts +++ b/src/fn/sot23.ts @@ -154,7 +154,95 @@ export const sot23_3 = (parameters: z.infer) => { layer: "top", } - return [...pads, silkscreenRefText as AnyCircuitElement, courtyard] + // Body outline. The pads overlap the body horizontally (a pad spans + // x[-1.8, -0.475] while the body edge sits at x=-0.91), so the outline is + // emitted as separate open paths that stop short of each pad instead of one + // closed rectangle. This is how KiCad's SOT-23-3 draws it, and the pads + // generated above are identical to KiCad's. + const bodyHalfWidth = 0.91 + const bodyHalfHeight = 1.56 + const padHalfHeight = pw / 2 + const strokeWidth = 0.12 + // Silkscreen must not touch copper. KiCad's SOT-23-3 leaves a uniform 0.26mm + // between each pad edge and the end of the adjacent silkscreen segment, + // which is half the stroke width plus IPC's 0.2mm silk-to-pad clearance. + const padClearance = strokeWidth / 2 + 0.2 + + const silkPath = ( + id: string, + route: { x: number; y: number }[], + ): PcbSilkscreenPath => ({ + type: "pcb_silkscreen_path", + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: id, + route, + stroke_width: strokeWidth, + }) + + const silkscreenPaths: PcbSilkscreenPath[] = [ + // Top and bottom edges run the full body width — no pad crosses them. + silkPath("silkscreen_path_top", [ + { x: -bodyHalfWidth, y: bodyHalfHeight }, + { x: bodyHalfWidth, y: bodyHalfHeight }, + ]), + silkPath("silkscreen_path_bottom", [ + { x: -bodyHalfWidth, y: -bodyHalfHeight }, + { x: bodyHalfWidth, y: -bodyHalfHeight }, + ]), + // Left edge, broken by pins 1 and 2: a stub above pin 1, the stretch + // between the two pins, and a stub below pin 2. + silkPath("silkscreen_path_left_top", [ + { x: -bodyHalfWidth, y: p + padHalfHeight + padClearance }, + { x: -bodyHalfWidth, y: bodyHalfHeight }, + ]), + silkPath("silkscreen_path_left_middle", [ + { x: -bodyHalfWidth, y: -(p - padHalfHeight - padClearance) }, + { x: -bodyHalfWidth, y: p - padHalfHeight - padClearance }, + ]), + silkPath("silkscreen_path_left_bottom", [ + { x: -bodyHalfWidth, y: -bodyHalfHeight }, + { x: -bodyHalfWidth, y: -(p + padHalfHeight + padClearance) }, + ]), + // Right edge, broken by pin 3. + silkPath("silkscreen_path_right_top", [ + { x: bodyHalfWidth, y: bodyHalfHeight }, + { x: bodyHalfWidth, y: padHalfHeight + padClearance }, + ]), + silkPath("silkscreen_path_right_bottom", [ + { x: bodyHalfWidth, y: -(padHalfHeight + padClearance) }, + { x: bodyHalfWidth, y: -bodyHalfHeight }, + ]), + ] + + // Pin 1 indicator: an open chevron outboard of pad 1, clear of the copper. + const pin1 = getCcwSot23Coords({ + num_pins: parameters.num_pins, + pn: 1, + w, + h, + pl, + p, + }) + // Apex points up at pad 1, base below it, tucked under the outboard end of + // the pad — the same placement KiCad uses. + const markerX = pin1.x - pl / 2 + 0.35 + const markerHalfWidth = 0.24 + const markerHeight = 0.33 + const markerApexY = pin1.y - padHalfHeight - padClearance + const pin1Indicator = silkPath("pin1_indicator", [ + { x: markerX, y: markerApexY }, + { x: markerX + markerHalfWidth, y: markerApexY - markerHeight }, + { x: markerX - markerHalfWidth, y: markerApexY - markerHeight }, + ]) + + return [ + ...pads, + ...(silkscreenPaths as AnyCircuitElement[]), + pin1Indicator as AnyCircuitElement, + silkscreenRefText as AnyCircuitElement, + courtyard, + ] } export const getCcwSot235Coords = (parameters: { diff --git a/tests/__snapshots__/sot23.snap.svg b/tests/__snapshots__/sot23.snap.svg index c4c986dea..c243d1e1b 100644 --- a/tests/__snapshots__/sot23.snap.svg +++ b/tests/__snapshots__/sot23.snap.svg @@ -1 +1 @@ -{REF} \ No newline at end of file +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/sot23_3.snap.svg b/tests/__snapshots__/sot23_3.snap.svg index c4c986dea..c243d1e1b 100644 --- a/tests/__snapshots__/sot23_3.snap.svg +++ b/tests/__snapshots__/sot23_3.snap.svg @@ -1 +1 @@ -{REF} \ No newline at end of file +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/sot23_3_micrometer_units.snap.svg b/tests/__snapshots__/sot23_3_micrometer_units.snap.svg index c4c986dea..c243d1e1b 100644 --- a/tests/__snapshots__/sot23_3_micrometer_units.snap.svg +++ b/tests/__snapshots__/sot23_3_micrometer_units.snap.svg @@ -1 +1 @@ -{REF} \ No newline at end of file +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/sot23_w3_h1.5_p0.95mm.snap.svg b/tests/__snapshots__/sot23_w3_h1.5_p0.95mm.snap.svg index 000739ff6..951079d31 100644 --- a/tests/__snapshots__/sot23_w3_h1.5_p0.95mm.snap.svg +++ b/tests/__snapshots__/sot23_w3_h1.5_p0.95mm.snap.svg @@ -1 +1 @@ -{REF} \ No newline at end of file +{REF} \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/sot23.snap.svg b/tests/kicad-parity/__snapshots__/sot23.snap.svg index d80cecc48..b552d43c1 100644 --- a/tests/kicad-parity/__snapshots__/sot23.snap.svg +++ b/tests/kicad-parity/__snapshots__/sot23.snap.svg @@ -1 +1 @@ -{REF}REF**Diff: 0.00% \ No newline at end of file +{REF}REF**Diff: 0.00% \ No newline at end of file diff --git a/tests/sot23.test.ts b/tests/sot23.test.ts index bc74f7c78..f2d226c15 100644 --- a/tests/sot23.test.ts +++ b/tests/sot23.test.ts @@ -90,3 +90,66 @@ test("sot23_6", () => { const svgContent = convertCircuitJsonToPcbSvg(circuitJson) expect(svgContent).toMatchSvgSnapshot(import.meta.path, "sot23_6") }) + +test("sot23_3 draws a silkscreen body outline and pin 1 marker", () => { + const circuitJson = fp.string("sot23").circuitJson() + + const silkscreenPaths = circuitJson.filter( + (e) => e.type === "pcb_silkscreen_path", + ) + + // Three broken edges (left split by pins 1/2, right split by pin 3, plus the + // top and bottom runs) and the pin 1 marker. + expect(silkscreenPaths.length).toBe(8) + expect( + silkscreenPaths.some((p) => p.pcb_silkscreen_path_id === "pin1_indicator"), + ).toBe(true) +}) + +test("sot23_3 silkscreen keeps clear of every pad", () => { + const circuitJson = fp.string("sot23").circuitJson() + + const pads = circuitJson.filter((e) => e.type === "pcb_smtpad") as any[] + const silkscreenPaths = circuitJson.filter( + (e) => e.type === "pcb_silkscreen_path", + ) as any[] + + const distanceToPad = (point: { x: number; y: number }, pad: any) => { + const dx = Math.max( + pad.x - pad.width / 2 - point.x, + 0, + point.x - (pad.x + pad.width / 2), + ) + const dy = Math.max( + pad.y - pad.height / 2 - point.y, + 0, + point.y - (pad.y + pad.height / 2), + ) + return Math.sqrt(dx * dx + dy * dy) + } + + let minClearance = Number.POSITIVE_INFINITY + for (const path of silkscreenPaths) { + const strokeHalfWidth = (path.stroke_width ?? 0) / 2 + for (let i = 0; i < path.route.length - 1; i++) { + const a = path.route[i] + const b = path.route[i + 1] + for (let t = 0; t <= 1; t += 0.01) { + const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t } + for (const pad of pads) { + minClearance = Math.min( + minClearance, + distanceToPad(point, pad) - strokeHalfWidth, + ) + } + } + } + } + + // Guard against this passing vacuously if the silkscreen ever disappears. + expect(silkscreenPaths.length).toBeGreaterThan(0) + expect(Number.isFinite(minClearance)).toBe(true) + + // IPC silk-to-pad clearance, the same 0.2mm KiCad's SOT-23-3 leaves. + expect(minClearance).toBeGreaterThanOrEqual(0.2 - 1e-6) +})