diff --git a/src/pcb/pcb_plated_hole.ts b/src/pcb/pcb_plated_hole.ts index 5aff2275..57ed7238 100644 --- a/src/pcb/pcb_plated_hole.ts +++ b/src/pcb/pcb_plated_hole.ts @@ -4,6 +4,68 @@ import { layer_ref, type LayerRef } from "src/pcb/properties/layer_ref" import { getZodPrefixedIdWithDefault } from "src/common" import { expectTypesMatch } from "src/utils/expect-types-match" +const pad_outline = z + .array( + z.object({ + x: distance, + y: distance, + }), + ) + .min(3) + +const pad_stackup_circle = z.object({ + type: z.literal("circle"), + outer_diameter: z.number(), +}) + +const pad_stackup_rect = z.object({ + type: z.literal("rect"), + width: z.number(), + height: z.number(), + border_radius: z.number().optional(), + ccw_rotation: rotation.optional(), +}) + +const pad_stackup_oval = z.object({ + type: z.literal("oval"), + width: z.number(), + height: z.number(), + ccw_rotation: rotation.optional(), +}) + +const pad_stackup_pill = z.object({ + type: z.literal("pill"), + width: z.number(), + height: z.number(), + ccw_rotation: rotation.optional(), +}) + +const pad_stackup_rotated_pill = z.object({ + type: z.literal("rotated_pill"), + width: z.number(), + height: z.number(), + ccw_rotation: rotation, +}) + +const pad_stackup_polygon = z.object({ + type: z.literal("polygon"), + pad_outline, +}) + +const pad_stackup_shape = z.discriminatedUnion("type", [ + pad_stackup_circle, + pad_stackup_rect, + pad_stackup_oval, + pad_stackup_pill, + pad_stackup_rotated_pill, + pad_stackup_polygon, +]) + +const pad_stackup_layer = z.object({ + layer: layer_ref, + shape: pad_stackup_shape, +}) + const pcb_plated_hole_circle = z.object({ type: z.literal("pcb_plated_hole"), shape: z.literal("circle"), @@ -20,6 +82,60 @@ const pcb_plated_hole_circle = z.object({ pcb_plated_hole_id: getZodPrefixedIdWithDefault("pcb_plated_hole"), }) +export interface PcbPadStackupCircle { + type: "circle" + outer_diameter: number +} + +export interface PcbPadStackupRect { + type: "rect" + width: number + height: number + border_radius?: number + ccw_rotation?: Rotation +} + +export interface PcbPadStackupOval { + type: "oval" + width: number + height: number + ccw_rotation?: Rotation +} + +export interface PcbPadStackupPill { + type: "pill" + width: number + height: number + ccw_rotation?: Rotation +} + +export interface PcbPadStackupRotatedPill { + type: "rotated_pill" + width: number + height: number + ccw_rotation: Rotation +} + +export interface PcbPadStackupPolygon { + type: "polygon" + pad_outline: { x: Distance; y: Distance }[] +} + +export type PcbPadStackupShape = + | PcbPadStackupCircle + | PcbPadStackupRect + | PcbPadStackupOval + | PcbPadStackupPill + | PcbPadStackupRotatedPill + | PcbPadStackupPolygon + +export interface PcbPadStackupLayer { + layer: LayerRef + shape: PcbPadStackupShape +} + +export type PcbPadStackup = PcbPadStackupLayer[] + /** * Defines a circular plated hole on the PCB */ @@ -226,14 +342,7 @@ const pcb_hole_with_polygon_pad = z.object({ hole_width: z.number().optional(), hole_height: z.number().optional(), - pad_outline: z - .array( - z.object({ - x: distance, - y: distance, - }), - ) - .min(3), + pad_outline, hole_offset_x: distance.default(0), hole_offset_y: distance.default(0), @@ -246,6 +355,67 @@ const pcb_hole_with_polygon_pad = z.object({ pcb_plated_hole_id: getZodPrefixedIdWithDefault("pcb_plated_hole"), }) +const pcb_hole_with_pad_stackup = z + .object({ + type: z.literal("pcb_plated_hole"), + shape: z.literal("hole_with_pad_stackup"), + pcb_group_id: z.string().optional(), + subcircuit_id: z.string().optional(), + hole_shape: z.enum(["circle", "oval", "pill", "rotated_pill"]), + hole_diameter: z.number().optional(), + hole_width: z.number().optional(), + hole_height: z.number().optional(), + hole_ccw_rotation: rotation.optional(), + pad_stackup: z.array(pad_stackup_layer).min(1), + hole_offset_x: distance.default(0), + hole_offset_y: distance.default(0), + x: distance, + y: distance, + layers: z.array(layer_ref), + port_hints: z.array(z.string()).optional(), + pcb_component_id: z.string().optional(), + pcb_port_id: z.string().optional(), + pcb_plated_hole_id: getZodPrefixedIdWithDefault("pcb_plated_hole"), + }) + .superRefine((value, ctx) => { + if (value.hole_shape === "circle") { + if (value.hole_diameter === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hole_diameter"], + message: "hole_diameter is required when hole_shape is circle", + }) + } + } else { + if (value.hole_width === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hole_width"], + message: "hole_width is required when hole_shape is not circle", + }) + } + if (value.hole_height === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hole_height"], + message: "hole_height is required when hole_shape is not circle", + }) + } + } + + if ( + value.hole_shape === "rotated_pill" && + value.hole_ccw_rotation === undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hole_ccw_rotation"], + message: + "hole_ccw_rotation is required when hole_shape is rotated_pill", + }) + } + }) + /** * Defines a plated hole with a polygonal pad on the PCB */ @@ -271,6 +441,28 @@ export interface PcbHoleWithPolygonPad { pcb_plated_hole_id: string } +export interface PcbHoleWithPadStackup { + type: "pcb_plated_hole" + shape: "hole_with_pad_stackup" + pcb_group_id?: string + subcircuit_id?: string + hole_shape: "circle" | "oval" | "pill" | "rotated_pill" + hole_diameter?: number + hole_width?: number + hole_height?: number + hole_ccw_rotation?: Rotation + pad_stackup: PcbPadStackup + hole_offset_x: Distance + hole_offset_y: Distance + x: Distance + y: Distance + layers: LayerRef[] + port_hints?: string[] + pcb_component_id?: string + pcb_port_id?: string + pcb_plated_hole_id: string +} + export const pcb_plated_hole = z.union([ pcb_plated_hole_circle, pcb_plated_hole_oval, @@ -278,6 +470,7 @@ export const pcb_plated_hole = z.union([ pcb_pill_hole_with_rect_pad, pcb_rotated_pill_hole_with_rect_pad, pcb_hole_with_polygon_pad, + pcb_hole_with_pad_stackup, ]) export type PcbPlatedHole = | PcbPlatedHoleCircle @@ -286,7 +479,22 @@ export type PcbPlatedHole = | PcbHolePillWithRectPad | PcbHoleRotatedPillWithRectPad | PcbHoleWithPolygonPad + | PcbHoleWithPadStackup +expectTypesMatch>(true) +expectTypesMatch>(true) +expectTypesMatch>(true) +expectTypesMatch>(true) +expectTypesMatch< + PcbPadStackupRotatedPill, + z.infer +>(true) +expectTypesMatch>( + true, +) +expectTypesMatch>(true) +expectTypesMatch>(true) +expectTypesMatch[]>(true) expectTypesMatch>( true, ) @@ -307,6 +515,10 @@ expectTypesMatch< PcbHoleWithPolygonPad, z.infer >(true) +expectTypesMatch< + PcbHoleWithPadStackup, + z.infer +>(true) /** * @deprecated use PcbPlatedHole */ diff --git a/tests/pcb_hole_with_pad_stackup.test.ts b/tests/pcb_hole_with_pad_stackup.test.ts new file mode 100644 index 00000000..8b3c4b4a --- /dev/null +++ b/tests/pcb_hole_with_pad_stackup.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test" +import { + pcb_plated_hole, + type PcbHoleWithPadStackup, +} from "../src/pcb/pcb_plated_hole" + +test("parse hole with pad stackup defaults hole offset to 0", () => { + const hole = pcb_plated_hole.parse({ + type: "pcb_plated_hole", + shape: "hole_with_pad_stackup", + hole_shape: "circle", + hole_diameter: 0.3, + x: 1, + y: 2, + layers: ["top", "bottom"], + pad_stackup: [ + { + layer: "top", + shape: { + type: "rect", + width: 0.9, + height: 0.7, + ccw_rotation: 15, + }, + }, + { + layer: "bottom", + shape: { + type: "polygon", + pad_outline: [ + { x: -0.4, y: -0.3 }, + { x: 0.4, y: -0.3 }, + { x: 0, y: 0.5 }, + ], + }, + }, + ], + }) as PcbHoleWithPadStackup + + expect(hole.hole_offset_x).toBe(0) + expect(hole.hole_offset_y).toBe(0) + expect(hole.pad_stackup[0]?.shape).toEqual({ + type: "rect", + width: 0.9, + height: 0.7, + ccw_rotation: 15, + }) + expect(hole.pad_stackup[1]?.shape.type).toBe("polygon") +}) + +test("hole with pad stackup requires hole dimensions for non-circular holes", () => { + expect(() => + pcb_plated_hole.parse({ + type: "pcb_plated_hole", + shape: "hole_with_pad_stackup", + hole_shape: "oval", + x: 0, + y: 0, + layers: ["top"], + pad_stackup: [ + { + layer: "top", + shape: { + type: "circle", + outer_diameter: 0.6, + }, + }, + ], + }), + ).toThrow() +}) + +test("hole with pad stackup requires rotation for rotated pill holes", () => { + expect(() => + pcb_plated_hole.parse({ + type: "pcb_plated_hole", + shape: "hole_with_pad_stackup", + hole_shape: "rotated_pill", + hole_width: 0.4, + hole_height: 0.9, + x: 0, + y: 0, + layers: ["top"], + pad_stackup: [ + { + layer: "top", + shape: { + type: "pill", + width: 0.6, + height: 0.9, + }, + }, + ], + }), + ).toThrow() + + const rotatedPillHole = pcb_plated_hole.parse({ + type: "pcb_plated_hole", + shape: "hole_with_pad_stackup", + hole_shape: "rotated_pill", + hole_width: 0.4, + hole_height: 0.9, + hole_ccw_rotation: 45, + x: 0, + y: 0, + layers: ["top"], + pad_stackup: [ + { + layer: "top", + shape: { + type: "rotated_pill", + width: 0.6, + height: 0.9, + ccw_rotation: 90, + }, + }, + ], + }) as PcbHoleWithPadStackup + + expect(rotatedPillHole.hole_ccw_rotation).toBe(45) +})