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
9 changes: 7 additions & 2 deletions lib/components/primitive-components/Hole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PcbHoleRect,
PcbHoleCircle,
} from "circuit-json"
import { optionalId } from "lib/utils/optional-id"

export class Hole extends PrimitiveComponent<typeof holeProps> {
pcb_hole_id: string | null = null
Expand Down Expand Up @@ -52,9 +53,13 @@ export class Hole extends PrimitiveComponent<typeof holeProps> {
const position = this._getGlobalPcbPositionBeforeLayout()
const soldermaskMargin = props.solderMaskMargin
const isCoveredWithSolderMask = props.coveredWithSolderMask ?? false
const pcb_component_id =
// `pcb_component_id` is `string | null` on PrimitiveComponent, and `??`
// only falls through on `undefined` — a board-level hole has no owning
// component, so the `null` used to reach circuit JSON and fail validation.
const pcb_component_id = optionalId(
this.parent?.pcb_component_id ??
this.getPrimitiveContainer()?.pcb_component_id
this.getPrimitiveContainer()?.pcb_component_id,
)

this.emitSolderMaskMarginWarning(isCoveredWithSolderMask, soldermaskMargin)

Expand Down
12 changes: 10 additions & 2 deletions lib/components/primitive-components/PlatedHole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "circuit-json"
import { decomposeTSR } from "transformation-matrix"
import { selectPortForPcbPrimitive } from "./Port/selectPortForPcbPrimitive"
import { optionalId } from "lib/utils/optional-id"

export class PlatedHole extends PrimitiveComponent<typeof platedHoleProps> {
pcb_plated_hole_id: string | null = null
Expand Down Expand Up @@ -125,9 +126,12 @@ export class PlatedHole extends PrimitiveComponent<typeof platedHoleProps> {
const { db } = this.root!
const { _parsedProps: props } = this
const position = this._getGlobalPcbPositionBeforeLayout()
const pcb_component_id =
// See `optionalId`: `pcb_component_id` is `string | null`, and a
// board-level plated hole has no owning component.
const pcb_component_id = optionalId(
this.parent?.pcb_component_id ??
this.getPrimitiveContainer()?.pcb_component_id!
this.getPrimitiveContainer()?.pcb_component_id,
)
const subcircuit = this.getSubcircuit()
const platedHoleLayers = this.getAvailablePcbLayers()
const soldermaskMargin = props.solderMaskMargin
Expand Down Expand Up @@ -265,6 +269,10 @@ export class PlatedHole extends PrimitiveComponent<typeof platedHoleProps> {
rect_pad_width: props.rectPadWidth,
rect_pad_height: props.rectPadHeight,
shape: "circular_hole_with_rect_pad" as const,
// Required by the `circular_hole_with_rect_pad` schema variant; the
// sibling pill variants below already set them.
hole_shape: "circle" as const,
pad_shape: "rect" as const,
port_hints: this.getNameAndAliases(),
x: position.x,
y: position.y,
Expand Down
13 changes: 13 additions & 0 deletions lib/utils/optional-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Normalizes an id that may be `null` into `undefined`.
*
* `PrimitiveComponent` initialises its id fields to `null`
* (`pcb_component_id: string | null = null`), but circuit-json types every id
* as `z.string().optional()` — which accepts `undefined` and rejects `null`.
*
* Primitives placed directly on a board have no owning `pcb_component`, so the
* `null` was reaching circuit JSON and making the whole element fail
* `any_circuit_element.parse()`.
*/
export const optionalId = (id: string | null | undefined): string | undefined =>
id ?? undefined
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, test } from "bun:test"
import { any_circuit_element } from "circuit-json"
import { getTestFixture } from "tests/fixtures/get-test-fixture"

test("board-level holes produce valid circuit json", async () => {
const { circuit } = getTestFixture()

circuit.add(
<board width="30mm" height="30mm" routingDisabled>
<hole name="H1" pcbX={-5} pcbY={5} diameter="1mm" />
<platedhole
name="PH1"
pcbX={5}
pcbY={-5}
shape="circle"
holeDiameter="0.5mm"
outerDiameter="0.9mm"
/>
{/* A footprint whose holes DO belong to a component. */}
<chip name="U1" footprint="dip8" pcbX={0} />
</board>,
)

await circuit.renderUntilSettled()

const circuitJson = circuit.getCircuitJson()
const holes = circuitJson.filter(
(e: any) => e.type === "pcb_hole" || e.type === "pcb_plated_hole",
) as any[]

const boardLevelHoles = holes.filter((h) => h.pcb_component_id === undefined)
const componentHoles = holes.filter((h) => h.pcb_component_id !== undefined)

expect(boardLevelHoles.length).toBeGreaterThan(0)
expect(componentHoles.length).toBeGreaterThan(0)

// `pcb_component_id` is `z.string().optional()` on these schemas, so `null`
// is rejected while an absent value is fine. A board-level hole has no
// owning component, and the `null` used to make the element fail
// `any_circuit_element.parse()` entirely.
for (const hole of boardLevelHoles) {
expect(hole.pcb_component_id).toBeUndefined()
}

// Holes that belong to a footprint must keep their component id.
for (const hole of componentHoles) {
expect(typeof hole.pcb_component_id).toBe("string")
}

for (const hole of holes) {
const result = any_circuit_element.safeParse(hole)
expect({ type: hole.type, valid: result.success }).toEqual({
type: hole.type,
valid: true,
})
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ test("PlatedHole pill shape", () => {
],
"outer_height": 4,
"outer_width": 2,
"pcb_component_id": null,
"pcb_component_id": undefined,
"pcb_group_id": undefined,
"pcb_plated_hole_id": "pcb_plated_hole_0",
"pcb_port_id": undefined,
Expand Down
Loading