Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: PR package preview
on:
pull_request:
permissions:
contents: read
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- run: bun install
- run: bun run build
- run: bunx pkg-pr-new@0.0.37 publish --comment=off
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,45 @@ npm install @tscircuit/cableprinter

The `bun-pver-release.yml` GitHub Actions workflow publishes releases through
npm trusted publishing, with signed provenance and no npm token secret.


## Bullet connectors

`bullet_d3.5mm` defaults to a male-to-female single-wire cable. Specify both end
genders with `bullet_d3.5mm_afemale_bmale`, `bullet_d4mm_amale_bmale`, or
`bullet_d4mm_afemale_bfemale`. Supported nominal mating diameters are 2, 3, 3.5,
4, 5, 5.5, 6, and 8 mm. Size is required; no connector diameter is inferred.

```ts
getCableDefinition({
standard: "bullet",
diameter: 3.5,
genderA: "male",
genderB: "female",
wireDiameter: 2, // insulation included, mm
color: "#df4049",
})
```

End genders default independently to male at A and female at B; wire diameter
defaults to 2 mm and color to `#263449`. Dimensions describe representative
solder bullets with gold contacts, male spring slots, female socket recesses,
and solder cups. Nominal contact diameter differs from socket outer diameter.
They are visualization models, not manufacturer-specific fabrication drawings.
The separate bullet gallery pairs code with actual meshes for every supported
diameter and a three-contact cable.

![Single and grouped bullet connectors](tests/__snapshots__/bullet-cables.snap.png)

Grouped bullets use `bullet3_d3.5mm` (three male/female contact pairs) or
`bullet3_d3.5mm_afemale_bmale`. The object form adds `pinCount: 3`; counts 1–16
are supported and default to 1. Each contact has its own insulated wire,
spaced at nominal diameter + 2 mm, with distinct display colors. The contact
count and diameter must match at both ends.

All modifiers are named and can appear in any order: `d` specifies the nominal
diameter, `a` specifies end A's gender, and `b` specifies end B's gender.
For example, `bullet3_bmale_d3.5mm_afemale` is equivalent to
`bullet3_d3.5mm_afemale_bmale`. Each end's gender is optional and defaults
independently. Positional modifiers, unknown names, and duplicate parameters
are rejected.
43 changes: 43 additions & 0 deletions lib/bullet-connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { z } from "zod"

/** Nominal mating-contact diameters in mm, independent of the outer socket. */
export const bulletDiameterSchema = z.union([
z.literal(2),
z.literal(3),
z.literal(3.5),
z.literal(4),
z.literal(5),
z.literal(5.5),
z.literal(6),
z.literal(8),
])
export type BulletDiameter = z.infer<typeof bulletDiameterSchema>
export const bulletGenderSchema = z.enum(["male", "female"])
export type BulletGender = z.infer<typeof bulletGenderSchema>

/** Representative solder bullets, not manufacturer-specific production models.
* Right-handed connector-local mm: mating tip/mouth at z=0, wire exits +Z.
*/
export function getBulletConnector({
diameter,
gender,
pinCount = 1,
}: {
diameter: BulletDiameter
gender: BulletGender
pinCount?: number
}) {
const contactDepth = diameter * 2
const bodyDiameter = diameter + (gender === "female" ? 1 : 0.6)
return {
kind:
gender === "male" ? ("bullet_male" as const) : ("bullet_female" as const),
diameter,
contactDepth,
pinCount,
pitch: diameter + 2,
bodyWidth: bodyDiameter + (pinCount - 1) * (diameter + 2),
bodyHeight: bodyDiameter,
bodyDepth: contactDepth + diameter * 1.5,
}
}
78 changes: 78 additions & 0 deletions lib/cable-definition.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod"
import { bulletDiameterSchema } from "./bullet-connector"
import { cableStandardSchema } from "./cable-input"

const dimension = z.number().finite().positive()
Expand All @@ -11,6 +12,26 @@ const body = {

/** Local mating face is at z=0, centered on x/y; the cable exits along +z. */
export const cableConnectorSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("bullet_male"),
...body,
diameter: bulletDiameterSchema,
pinCount: z.number().int().min(1).max(16).default(1),
pitch: dimension,
contactDepth: dimension,
})
.strict(),
z
.object({
kind: z.literal("bullet_female"),
...body,
diameter: bulletDiameterSchema,
pinCount: z.number().int().min(1).max(16).default(1),
pitch: dimension,
contactDepth: dimension,
})
.strict(),
z
.object({
kind: z.literal("usb_c_plug"),
Expand Down Expand Up @@ -74,13 +95,70 @@ export const cableDefinitionSchema = z
jst_sh: ["jst_sh_housing", "jst_sh_housing"],
jst_ph: ["jst_ph_housing", "jst_ph_housing"],
us_mains: ["nema_5_15p", "iec_c13"],
bullet: [connectorA.kind, connectorB.kind],
}[cable.standard]
if (connectorA.kind !== expected[0] || connectorB.kind !== expected[1]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Connector kinds must match the cable standard",
})
}
if (cable.standard === "bullet") {
if (
!("diameter" in connectorA) ||
!("diameter" in connectorB) ||
connectorA.diameter !== connectorB.diameter ||
("pinCount" in connectorA &&
"pinCount" in connectorB &&
connectorA.pinCount !== connectorB.pinCount)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Bullet connectors require matching nominal diameters and contact counts",
})
}
if (
"pinCount" in connectorA &&
((connectorA.pinCount === 1 && crossSection.kind !== "round_jacket") ||
(connectorA.pinCount > 1 && crossSection.kind !== "wire_bundle"))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bullet cables require one insulated wire per contact",
})
}
for (const connector of [connectorA, connectorB]) {
if (
"diameter" in connector &&
(connector.bodyHeight <= connector.diameter ||
connector.pitch < connector.bodyHeight ||
Math.abs(
connector.bodyWidth -
(connector.bodyHeight +
(connector.pinCount - 1) * connector.pitch),
) > 1e-6 ||
(crossSection.kind === "wire_bundle" &&
(crossSection.wirePitch !== connector.pitch ||
crossSection.wires.some(
(wire) => wire.diameter > connector.bodyHeight,
))) ||
connector.contactDepth >= connector.bodyDepth ||
(crossSection.kind === "round_jacket" &&
crossSection.diameter > connector.bodyHeight))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bullet contact, body, and wire dimensions must fit",
})
}
}
} else if ("diameter" in connectorA || "diameter" in connectorB) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bullet connectors require the bullet cable standard",
})
}
if (crossSection.kind === "wire_bundle") {
for (const connector of [connectorA, connectorB]) {
if (
Expand Down
13 changes: 13 additions & 0 deletions lib/cable-input.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { z } from "zod"
import { bulletDiameterSchema, bulletGenderSchema } from "./bullet-connector"

export const cableStandardSchema = z.enum([
"usb_c",
"jst_sh",
"jst_ph",
"us_mains",
"bullet",
])
export type CableStandard = z.infer<typeof cableStandardSchema>

Expand All @@ -16,6 +18,17 @@ const jacket = {

/** Dimensions are millimeters; wireDiameter includes insulation. */
export const cableInputSchema = z.discriminatedUnion("standard", [
z
.object({
standard: z.literal("bullet"),
diameter: bulletDiameterSchema,
pinCount: z.number().int().min(1).max(16).default(1),
genderA: bulletGenderSchema.default("male"),
genderB: bulletGenderSchema.default("female"),
wireDiameter: z.number().finite().positive().default(2),
color: color.default("#263449"),
})
.strict(),
z.object({ standard: z.literal("usb_c"), ...jacket }).strict(),
z
.object({
Expand Down
31 changes: 31 additions & 0 deletions lib/get-cable-definition.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getBulletConnector } from "./bullet-connector"
import { type CableDefinition, cableDefinitionSchema } from "./cable-definition"
import { type CableInput, cableInputSchema } from "./cable-input"

Expand All @@ -12,6 +13,36 @@ const wireColors = [

export function getCableDefinition(cableInput: CableInput): CableDefinition {
const cable = cableInputSchema.parse(cableInput)
if (cable.standard === "bullet") {
return cableDefinitionSchema.parse({
standard: "bullet",
connectorA: getBulletConnector({
diameter: cable.diameter,
gender: cable.genderA,
pinCount: cable.pinCount,
}),
connectorB: getBulletConnector({
diameter: cable.diameter,
gender: cable.genderB,
pinCount: cable.pinCount,
}),
crossSection:
cable.pinCount === 1
? {
kind: "round_jacket",
diameter: cable.wireDiameter,
color: cable.color,
}
: {
kind: "wire_bundle",
wirePitch: cable.diameter + 2,
wires: Array.from({ length: cable.pinCount }, (_, index) => ({
diameter: cable.wireDiameter,
color: wireColors[index % wireColors.length],
})),
},
})
}
if (cable.standard === "usb_c") {
const connector = {
kind: "usb_c_plug" as const,
Expand Down
7 changes: 7 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ export {
export type { CableConnector, CableDefinition } from "./cable-definition"
export { getCableDefinition } from "./get-cable-definition"
export { parseCableString } from "./parse-cable-string"

export {
bulletDiameterSchema,
bulletGenderSchema,
getBulletConnector,
} from "./bullet-connector"
export type { BulletDiameter, BulletGender } from "./bullet-connector"
32 changes: 31 additions & 1 deletion lib/parse-cable-string.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bulletDiameterSchema } from "./bullet-connector"
import { type CableDefinition } from "./cable-definition"
import { getCableDefinition } from "./get-cable-definition"

Expand All @@ -7,6 +8,35 @@ export function parseCableString(cableString: string): CableDefinition {
if (cableName === "usb_c" || cableName === "us_mains") {
return getCableDefinition({ standard: cableName })
}
const bulletMatch = /^bullet([1-9][0-9]*)?_(.+)$/.exec(cableName)
if (bulletMatch) {
let diameter: number | undefined
let genderA: "male" | "female" | undefined
let genderB: "male" | "female" | undefined
const seen = new Set<string>()
for (const parameter of bulletMatch[2]!.split("_")) {
const match =
/^(d)([0-9]+(?:\.[0-9]+)?)mm$/.exec(parameter) ??
/^(a|b)(male|female)$/.exec(parameter)
if (!match || seen.has(match[1]!))
throw new Error(`Invalid or duplicate bullet parameter: "${parameter}"`)
seen.add(match[1]!)
if (match[1] === "d") diameter = Number(match[2])
else if (match[1] === "a") genderA = match[2] as "male" | "female"
else genderB = match[2] as "male" | "female"
}
if (diameter === undefined)
throw new Error(
"Bullet cables require a named diameter parameter, e.g. d3.5mm",
)
return getCableDefinition({
standard: "bullet",
diameter: bulletDiameterSchema.parse(diameter),
pinCount: bulletMatch[1] ? Number(bulletMatch[1]) : 1,
genderA: genderA ?? "male",
genderB: genderB ?? "female",
})
}
const jstMatch = /^(jst_sh|jst_ph)(?:_pins([0-9]+))?$/.exec(cableName)
if (jstMatch) {
return getCableDefinition({
Expand All @@ -15,6 +45,6 @@ export function parseCableString(cableString: string): CableDefinition {
})
}
throw new Error(
`Unsupported cable string: "${cableString}". Expected usb_c, jst_sh[_pinsN], jst_ph[_pinsN], or us_mains.`,
`Unsupported cable string: "${cableString}". Expected usb_c, jst_sh[_pinsN], jst_ph[_pinsN], us_mains, or bullet[CONTACTS]_dNmm[_amale][_bfemale].`,
)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"gl-matrix": "^3.4.4",
"jscad-electronics": "^0.0.187",
"jscad-electronics": "https://pkg.pr.new/tscircuit/jscad-electronics@b21b030",
"looks-same": "^10.0.1",
"poppygl": "0.0.30",
"react": "^19.1.1",
Expand Down
41 changes: 41 additions & 0 deletions pages/bullet-cable-examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseCableString } from "../lib"

export const bulletCableExamples = [
{
cableString: "bullet3_d3.5mm",
title: "Bullet group / three 3.5 mm contacts",
subtitle: "Three separate male/female pairs / three insulated wires",
annotation: "5.5 mm contact pitch / three electrical circuits",
code: [
'parseCableString("bullet3_d3.5mm_amale_bfemale")',
"",
"getCableDefinition({",
' standard: "bullet",',
" diameter: 3.5,",
" pinCount: 3,",
' genderA: "male",',
' genderB: "female",',
"})",
],
},
...[2, 3, 3.5, 4, 5, 5.5, 6, 8].map((diameter) => ({
cableString: `bullet_d${diameter}mm`,
title: `Bullet / ${diameter} mm`,
subtitle: "Gold solder bullet / male to female / single insulated wire",
annotation:
"Representative geometry; nominal mating diameter differs from socket outer diameter",
code: [
`parseCableString("bullet_d${diameter}mm_amale_bfemale")`,
"",
"getCableDefinition({",
' standard: "bullet",',
` diameter: ${diameter},`,
' genderA: "male",',
' genderB: "female",',
"})",
],
})),
].map((example) => ({
...example,
cable: parseCableString(example.cableString),
}))
Loading
Loading