diff --git a/README.md b/README.md index f0ae73f..5b9d7f0 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ 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. +Contact counts 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. @@ -166,3 +166,49 @@ 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. + +### Independent-ended adapter cables + +The generic wrapper names two independently specified connectors: + +```text +adaptercable_a(bullet3_d3.5mm_gfemale)_b(bullet3_d4mm_gfemale) +adaptercable_a(bullet3_d3.5mm_gfemale)_b(jst_ph_pins3) +adaptercable_a(jst_sh_pins4)_b(jst_ph_pins4) +``` + +The first example mates with a motor's three 3.5 mm male outputs and a board's +4 mm male plugs. Use `gmale` at B for female board sockets. `g` names a single +connector's gender; `d` names its diameter. Both modifiers are required for +bullet connector strings. A and B may appear in either order; duplicates, +missing ends, positional parameters, nested cables, and trailing tokens fail. +There are no `da`/`db` parameters or `diameterA`/`diameterB` object properties. + +Each end is parsed by `parseConnectorString`. Supported connector strings are +`bullet[CONTACTS]_dNmm_gmale|gfemale`, `jst_sh[_pinsN]`, `jst_ph[_pinsN]`, +`usb_c`, `nema_5_15p`, and `iec_c13`. `us_mains` is a two-ended cable preset, +not a connector name. Explicit contact counts must match. The string describes +physical mating interfaces, not electrical pin mapping or ratings. + +```ts +getCableDefinition({ + standard: "adaptercable", + connectorA: parseConnectorString("bullet3_d3.5mm_gfemale"), + connectorB: parseConnectorString("bullet3_d4mm_gfemale"), +}) +``` + +`getAdapterCableDefinition({ connectorA, connectorB, crossSection? })` exposes +the same composition API. An optional cross section configures wire sizes and +colors. Defaults use separate wires for two explicitly counted multi-contact +ends, or a round jacket otherwise. Each end retains its native contact pitch; +for different pitches, the renderer keeps a compact middle bundle and fans out +within the last 20 mm at either end. Short routes use shorter transitions, +preserving a compact middle third. The supplied route and connector poses stay intact. +Existing cable preset strings and their snapshots remain unchanged. + +`stringifyConnector` and `stringifyCableDefinition` emit canonical stock strings; +custom dimensions and cross sections stay in the object API. The optional +`useShorthand` serializer flag emits existing cable presets when applicable. + +![Generic adapters with independent bullet and JST connectors](tests/__snapshots__/bullet-adapter-cables.snap.png) diff --git a/lib/bullet-connector.ts b/lib/bullet-connector.ts index 39d135c..45ce319 100644 --- a/lib/bullet-connector.ts +++ b/lib/bullet-connector.ts @@ -22,10 +22,12 @@ export function getBulletConnector({ diameter, gender, pinCount = 1, + pitch = diameter + 2, }: { diameter: BulletDiameter gender: BulletGender pinCount?: number + pitch?: number }) { const contactDepth = diameter * 2 const bodyDiameter = diameter + (gender === "female" ? 1 : 0.6) @@ -35,8 +37,8 @@ export function getBulletConnector({ diameter, contactDepth, pinCount, - pitch: diameter + 2, - bodyWidth: bodyDiameter + (pinCount - 1) * (diameter + 2), + pitch, + bodyWidth: bodyDiameter + (pinCount - 1) * pitch, bodyHeight: bodyDiameter, bodyDepth: contactDepth + diameter * 1.5, } diff --git a/lib/cable-definition.ts b/lib/cable-definition.ts index 3d5cc87..54496a3 100644 --- a/lib/cable-definition.ts +++ b/lib/cable-definition.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { bulletDiameterSchema } from "./bullet-connector" -import { cableStandardSchema } from "./cable-input" +import { cableStandardSchema } from "./cable-standard" const dimension = z.number().finite().positive() const color = z.string().regex(/^#[0-9a-fA-F]{6}$/) @@ -96,6 +96,7 @@ export const cableDefinitionSchema = z jst_ph: ["jst_ph_housing", "jst_ph_housing"], us_mains: ["nema_5_15p", "iec_c13"], bullet: [connectorA.kind, connectorB.kind], + adaptercable: [connectorA.kind, connectorB.kind], }[cable.standard] if (connectorA.kind !== expected[0] || connectorB.kind !== expected[1]) { ctx.addIssue({ @@ -115,7 +116,7 @@ export const cableDefinitionSchema = z ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "Bullet connectors require matching nominal diameters and contact counts", + "Bullet connectors require supported nominal diameters and matching contact counts", }) } if ( @@ -153,12 +154,61 @@ export const cableDefinitionSchema = z }) } } - } else if ("diameter" in connectorA || "diameter" in connectorB) { + } else if ( + cable.standard !== "adaptercable" && + ("diameter" in connectorA || "diameter" in connectorB) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Bullet connectors require the bullet cable standard", }) } + if (cable.standard === "adaptercable") { + if ( + "pinCount" in connectorA && + "pinCount" in connectorB && + connectorA.pinCount !== connectorB.pinCount + ) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Adapter connector pin counts must match", + }) + if ( + "pinCount" in connectorA && + "pinCount" in connectorB && + connectorA.pinCount > 1 && + crossSection.kind !== "wire_bundle" + ) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Adapter cables require one wire per explicit 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 || + connector.contactDepth >= connector.bodyDepth || + (connector.pinCount === 1 && + (crossSection.kind !== "round_jacket" || + crossSection.diameter > connector.bodyHeight)) || + (connector.pinCount > 1 && + (crossSection.kind !== "wire_bundle" || + crossSection.wires.some( + (wire) => wire.diameter > connector.bodyHeight, + )))) + ) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bullet contact, body, and wire dimensions must fit", + }) + } + } if (crossSection.kind === "wire_bundle") { for (const connector of [connectorA, connectorB]) { if ( @@ -170,6 +220,14 @@ export const cableDefinitionSchema = z message: "Bundle wire count must match each connector pin count", }) } + if ( + "pitch" in connector && + crossSection.wires.some((wire) => wire.diameter > connector.pitch) + ) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Insulated wires must fit each connector pitch", + }) } if ( crossSection.wires.some( diff --git a/lib/cable-input.ts b/lib/cable-input.ts index 65d3878..0b74f08 100644 --- a/lib/cable-input.ts +++ b/lib/cable-input.ts @@ -1,14 +1,21 @@ import { z } from "zod" import { bulletDiameterSchema, bulletGenderSchema } from "./bullet-connector" +import { + cableConnectorSchema, + cableCrossSectionSchema, +} from "./cable-definition" +export { cableStandardSchema } from "./cable-standard" +export type { CableStandard } from "./cable-standard" -export const cableStandardSchema = z.enum([ - "usb_c", - "jst_sh", - "jst_ph", - "us_mains", - "bullet", -]) -export type CableStandard = z.infer +export const adapterCableInputSchema = z + .object({ + standard: z.literal("adaptercable"), + connectorA: cableConnectorSchema, + connectorB: cableConnectorSchema, + crossSection: cableCrossSectionSchema.optional(), + }) + .strict() +export type AdapterCableInput = z.input const color = z.string().regex(/^#[0-9a-fA-F]{6}$/) const jacket = { @@ -51,6 +58,7 @@ export const cableInputSchema = z.discriminatedUnion("standard", [ jacketDiameter: jacket.jacketDiameter.default(6.2), }) .strict(), + adapterCableInputSchema, ]) export type CableInput = z.input diff --git a/lib/cable-standard.ts b/lib/cable-standard.ts new file mode 100644 index 0000000..6d0d272 --- /dev/null +++ b/lib/cable-standard.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export const cableStandardSchema = z.enum([ + "usb_c", + "jst_sh", + "jst_ph", + "us_mains", + "bullet", + "adaptercable", +]) +export type CableStandard = z.infer diff --git a/lib/get-adapter-cable-definition.ts b/lib/get-adapter-cable-definition.ts new file mode 100644 index 0000000..f812730 --- /dev/null +++ b/lib/get-adapter-cable-definition.ts @@ -0,0 +1,56 @@ +import { cableDefinitionSchema, type CableDefinition } from "./cable-definition" +import { adapterCableInputSchema, type AdapterCableInput } from "./cable-input" + +const wireColors = [ + "#df4049", + "#263449", + "#e1b13c", + "#347dc9", + "#27a782", + "#af62bd", +] + +/** Compose independent mating interfaces. Dimensions are connector-local mm; + * this describes the cable, never its world placement or route. + */ +export function getAdapterCableDefinition( + input: Omit, +): CableDefinition { + const cable = adapterCableInputSchema.parse({ + standard: "adaptercable", + ...input, + }) + const { connectorA, connectorB } = cable + const bundled = + "pinCount" in connectorA && + "pinCount" in connectorB && + connectorA.pinCount > 1 + const pitches = [connectorA, connectorB].flatMap((connector) => + "pitch" in connector ? [connector.pitch] : [], + ) + const wireDiameter = Math.min(2, ...pitches.map((pitch) => pitch * 0.6)) + return cableDefinitionSchema.parse({ + ...cable, + crossSection: + cable.crossSection ?? + (bundled + ? { + kind: "wire_bundle", + wirePitch: Math.max(...pitches), + wires: Array.from({ length: connectorA.pinCount }, (_, index) => ({ + diameter: wireDiameter, + color: wireColors[index % wireColors.length], + })), + } + : { + kind: "round_jacket", + diameter: Math.min( + 4, + connectorA.bodyHeight, + connectorB.bodyHeight, + ...pitches.map((pitch) => Math.min(2, pitch * 0.6)), + ), + color: "#263449", + }), + }) +} diff --git a/lib/get-cable-definition.ts b/lib/get-cable-definition.ts index f244daa..aee5af1 100644 --- a/lib/get-cable-definition.ts +++ b/lib/get-cable-definition.ts @@ -1,4 +1,5 @@ import { getBulletConnector } from "./bullet-connector" +import { getAdapterCableDefinition } from "./get-adapter-cable-definition" import { type CableDefinition, cableDefinitionSchema } from "./cable-definition" import { type CableInput, cableInputSchema } from "./cable-input" @@ -13,18 +14,22 @@ const wireColors = [ export function getCableDefinition(cableInput: CableInput): CableDefinition { const cable = cableInputSchema.parse(cableInput) + if (cable.standard === "adaptercable") return getAdapterCableDefinition(cable) if (cable.standard === "bullet") { + const pitch = cable.diameter + 2 return cableDefinitionSchema.parse({ standard: "bullet", connectorA: getBulletConnector({ diameter: cable.diameter, gender: cable.genderA, pinCount: cable.pinCount, + pitch, }), connectorB: getBulletConnector({ diameter: cable.diameter, gender: cable.genderB, pinCount: cable.pinCount, + pitch, }), crossSection: cable.pinCount === 1 @@ -35,7 +40,7 @@ export function getCableDefinition(cableInput: CableInput): CableDefinition { } : { kind: "wire_bundle", - wirePitch: cable.diameter + 2, + wirePitch: pitch, wires: Array.from({ length: cable.pinCount }, (_, index) => ({ diameter: cable.wireDiameter, color: wireColors[index % wireColors.length], diff --git a/lib/index.ts b/lib/index.ts index 554e28b..0027c3c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,8 +1,13 @@ -export { cableStandardSchema, cableInputSchema } from "./cable-input" +export { + cableStandardSchema, + cableInputSchema, + adapterCableInputSchema, +} from "./cable-input" export type { CableStandard, CableInput, NormalizedCableInput, + AdapterCableInput, } from "./cable-input" export { cableConnectorSchema, @@ -12,6 +17,12 @@ export { export type { CableConnector, CableDefinition } from "./cable-definition" export { getCableDefinition } from "./get-cable-definition" export { parseCableString } from "./parse-cable-string" +export { parseConnectorString } from "./parse-connector-string" +export { getAdapterCableDefinition } from "./get-adapter-cable-definition" +export { + stringifyConnector, + stringifyCableDefinition, +} from "./stringify-cable-definition" export { bulletDiameterSchema, diff --git a/lib/parse-cable-string.ts b/lib/parse-cable-string.ts index 3547dbb..44fdbb3 100644 --- a/lib/parse-cable-string.ts +++ b/lib/parse-cable-string.ts @@ -1,13 +1,29 @@ import { bulletDiameterSchema } from "./bullet-connector" -import { type CableDefinition } from "./cable-definition" +import type { CableDefinition } from "./cable-definition" +import { getAdapterCableDefinition } from "./get-adapter-cable-definition" import { getCableDefinition } from "./get-cable-definition" +import { parseConnectorString } from "./parse-connector-string" -/** Examples: usb_c, jst_sh_pins4, jst_ph_pins6, us_mains. */ +/** Named cable presets, or adaptercable_a(CONNECTOR)_b(CONNECTOR). */ export function parseCableString(cableString: string): CableDefinition { const cableName = cableString.trim().toLowerCase() - if (cableName === "usb_c" || cableName === "us_mains") { - return getCableDefinition({ standard: cableName }) + if (cableName.startsWith("adaptercable")) { + const adapter = /^adaptercable_(a|b)\(([^()]+)\)_(a|b)\(([^()]+)\)$/.exec( + cableName, + ) + if (!adapter || adapter[1] === adapter[3]) + throw new Error( + "Adapter cables require exactly one named a(CONNECTOR) and b(CONNECTOR)", + ) + const first = parseConnectorString(adapter[2]!) + const second = parseConnectorString(adapter[4]!) + return getAdapterCableDefinition({ + connectorA: adapter[1] === "a" ? first : second, + connectorB: adapter[1] === "b" ? first : second, + }) } + 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 @@ -16,19 +32,17 @@ export function parseCableString(cableString: string): CableDefinition { const seen = new Set() for (const parameter of bulletMatch[2]!.split("_")) { const match = - /^(d)([0-9]+(?:\.[0-9]+)?)mm$/.exec(parameter) ?? + /^d([0-9]+(?:\.[0-9]+)?)mm$/.exec(parameter) ?? /^(a|b)(male|female)$/.exec(parameter) - if (!match || seen.has(match[1]!)) + const key = parameter[0]! + if (!match || seen.has(key)) 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" + seen.add(key) + if (key === "d") diameter = Number(match[1]) + else if (key === "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", - ) + if (diameter === undefined) throw new Error("Bullet cables require dNmm") return getCableDefinition({ standard: "bullet", diameter: bulletDiameterSchema.parse(diameter), @@ -38,13 +52,12 @@ export function parseCableString(cableString: string): CableDefinition { }) } const jstMatch = /^(jst_sh|jst_ph)(?:_pins([0-9]+))?$/.exec(cableName) - if (jstMatch) { + if (jstMatch) return getCableDefinition({ - standard: jstMatch[1] === "jst_sh" ? "jst_sh" : "jst_ph", + standard: jstMatch[1] as "jst_sh" | "jst_ph", ...(jstMatch[2] ? { pinCount: Number(jstMatch[2]) } : {}), }) - } throw new Error( - `Unsupported cable string: "${cableString}". Expected usb_c, jst_sh[_pinsN], jst_ph[_pinsN], us_mains, or bullet[CONTACTS]_dNmm[_amale][_bfemale].`, + `Unsupported cable string: "${cableString}". Expected usb_c, jst_sh[_pinsN], jst_ph[_pinsN], us_mains, bullet[CONTACTS]_dNmm[_amale][_bfemale], or adaptercable_a(CONNECTOR)_b(CONNECTOR).`, ) } diff --git a/lib/parse-connector-string.ts b/lib/parse-connector-string.ts new file mode 100644 index 0000000..1c63313 --- /dev/null +++ b/lib/parse-connector-string.ts @@ -0,0 +1,50 @@ +import { bulletDiameterSchema } from "./bullet-connector" +import type { CableConnector } from "./cable-definition" +import { getCableDefinition } from "./get-cable-definition" + +/** Parse one mating interface, not a cable with two ends. */ +export function parseConnectorString(connectorString: string): CableConnector { + const name = connectorString.trim().toLowerCase() + const bullet = /^bullet([1-9][0-9]*)?_(.+)$/.exec(name) + if (bullet) { + let diameter: number | undefined + let gender: "male" | "female" | undefined + const seen = new Set() + for (const parameter of bullet[2]!.split("_")) { + const match = + /^d([0-9]+(?:\.[0-9]+)?)mm$/.exec(parameter) ?? + /^g(male|female)$/.exec(parameter) + const key = parameter[0]! + if (!match || seen.has(key)) + throw new Error( + `Invalid or duplicate connector parameter: "${parameter}"`, + ) + seen.add(key) + if (key === "d") diameter = Number(match[1]) + else gender = match[1] as "male" | "female" + } + if (diameter === undefined || gender === undefined) + throw new Error( + "Bullet connector strings require dNmm and gmale or gfemale", + ) + return getCableDefinition({ + standard: "bullet", + diameter: bulletDiameterSchema.parse(diameter), + pinCount: bullet[1] ? Number(bullet[1]) : 1, + genderA: gender, + }).connectorA + } + const jst = /^(jst_sh|jst_ph)(?:_pins([0-9]+))?$/.exec(name) + if (jst) + return getCableDefinition({ + standard: jst[1] as "jst_sh" | "jst_ph", + ...(jst[2] ? { pinCount: Number(jst[2]) } : {}), + }).connectorA + if (name === "usb_c") + return getCableDefinition({ standard: "usb_c" }).connectorA + if (name === "nema_5_15p") + return getCableDefinition({ standard: "us_mains" }).connectorA + if (name === "iec_c13") + return getCableDefinition({ standard: "us_mains" }).connectorB + throw new Error(`Unsupported connector string: "${connectorString}"`) +} diff --git a/lib/stringify-cable-definition.ts b/lib/stringify-cable-definition.ts new file mode 100644 index 0000000..a982640 --- /dev/null +++ b/lib/stringify-cable-definition.ts @@ -0,0 +1,47 @@ +import type { CableConnector, CableDefinition } from "./cable-definition" + +/** Canonical stock connector DSL; custom mesh dimensions are not encoded. */ +export function stringifyConnector(connector: CableConnector): string { + switch (connector.kind) { + case "bullet_male": + case "bullet_female": + return `bullet${connector.pinCount === 1 ? "" : connector.pinCount}_d${connector.diameter}mm_g${connector.kind === "bullet_male" ? "male" : "female"}` + case "jst_sh_housing": + return `jst_sh_pins${connector.pinCount}` + case "jst_ph_housing": + return `jst_ph_pins${connector.pinCount}` + case "usb_c_plug": + return "usb_c" + case "nema_5_15p": + return "nema_5_15p" + case "iec_c13": + return "iec_c13" + } +} + +/** Serialize mating interfaces. Custom cross sections remain object API + * properties. useShorthand retains existing cable presets where applicable. + */ +export function stringifyCableDefinition( + definition: CableDefinition, + { useShorthand = false }: { useShorthand?: boolean } = {}, +): string { + const { connectorA: a, connectorB: b } = definition + if (definition.standard !== "adaptercable" || useShorthand) { + if ( + "diameter" in a && + "diameter" in b && + a.diameter === b.diameter && + a.pinCount === b.pinCount + ) + return `bullet${a.pinCount === 1 ? "" : a.pinCount}_d${a.diameter}mm_a${a.kind === "bullet_male" ? "male" : "female"}_b${b.kind === "bullet_male" ? "male" : "female"}` + if ( + !("diameter" in a) && + a.kind === b.kind && + (!("pinCount" in a) || !("pinCount" in b) || a.pinCount === b.pinCount) + ) + return stringifyConnector(a) + if (a.kind === "nema_5_15p" && b.kind === "iec_c13") return "us_mains" + } + return `adaptercable_a(${stringifyConnector(a)})_b(${stringifyConnector(b)})` +} diff --git a/package.json b/package.json index 0136b89..a411719 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "gl-matrix": "^3.4.4", - "jscad-electronics": "https://pkg.pr.new/tscircuit/jscad-electronics@b21b030", + "jscad-electronics": "^0.0.210", "looks-same": "^10.0.1", "poppygl": "0.0.30", "react": "^19.1.1", diff --git a/pages/bullet-adapter-examples.ts b/pages/bullet-adapter-examples.ts new file mode 100644 index 0000000..0abf3f3 --- /dev/null +++ b/pages/bullet-adapter-examples.ts @@ -0,0 +1,40 @@ +import { parseCableString } from "../lib" + +export const bulletAdapterExamples = [1, 3].map((pinCount) => { + const prefix = `bullet${pinCount === 1 ? "" : pinCount}` + const cableString = `adaptercable_a(${prefix}_d3.5mm_gfemale)_b(${prefix}_d4mm_gfemale)` + return { + cableString, + title: `${pinCount} contact${pinCount === 1 ? "" : "s"} / 3.5 mm female to 4 mm female`, + subtitle: "Adapter cable for male motor outputs and male board plugs", + annotation: + "A: 3.5 mm female at 5.5 mm pitch / B: 4 mm female at 6 mm pitch", + code: [`parseCableString("${cableString}")`], + cable: parseCableString(cableString), + } +}) + +for (const [a, b, title, annotation] of [ + [ + "bullet3_d3.5mm_gfemale", + "jst_ph_pins3", + "Bullet to JST PH / three contacts", + "A: three 3.5 mm female bullets / B: three-pin 2 mm pitch PH housing", + ], + [ + "jst_sh_pins4", + "jst_ph_pins4", + "JST SH to PH / four contacts", + "A: 1 mm pitch SH housing / B: 2 mm pitch PH housing / four wires", + ], +]) { + const cableString = `adaptercable_a(${a})_b(${b})` + bulletAdapterExamples.push({ + cableString, + title: title!, + subtitle: "Independent connector families and contact pitches", + annotation: annotation!, + code: [`parseCableString("${cableString}")`], + cable: parseCableString(cableString), + }) +} diff --git a/pages/bullet-adapters.page.tsx b/pages/bullet-adapters.page.tsx new file mode 100644 index 0000000..2eee653 --- /dev/null +++ b/pages/bullet-adapters.page.tsx @@ -0,0 +1,9 @@ +import snapshotUrl from "../tests/__snapshots__/bullet-adapter-cables.snap.png?url" + +export default ( + Generic adapter cables with independent bullet and JST connectors +) diff --git a/pages/playground.page.tsx b/pages/playground.page.tsx index 98b56c6..627b7cc 100644 --- a/pages/playground.page.tsx +++ b/pages/playground.page.tsx @@ -4,14 +4,16 @@ import { bulletDiameterSchema, type BulletDiameter, type BulletGender, - type CableStandard, getCableDefinition, } from "../lib" import { previewCableMeshes, previewConnectorMeshes } from "./cable-meshes" import { MeshCanvas } from "./components/mesh-canvas" +const presetStandardSchema = cableStandardSchema.exclude(["adaptercable"]) + export default function Playground() { - const [standard, setStandard] = useState("jst_sh") + const [standard, setStandard] = + useState<(typeof presetStandardSchema.options)[number]>("jst_sh") const [pinCount, setPinCount] = useState(4) const [diameter, setDiameter] = useState(3.5) const [genderA, setGenderA] = useState("male") @@ -44,7 +46,7 @@ export default function Playground() { diff --git a/tests/__snapshots__/bullet-adapter-cables.snap.png b/tests/__snapshots__/bullet-adapter-cables.snap.png new file mode 100644 index 0000000..bae130a Binary files /dev/null and b/tests/__snapshots__/bullet-adapter-cables.snap.png differ diff --git a/tests/bullet-adapter-cables.test.ts b/tests/bullet-adapter-cables.test.ts new file mode 100644 index 0000000..07ffb9d --- /dev/null +++ b/tests/bullet-adapter-cables.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test" +import { + cableInputSchema, + cableDefinitionSchema, + getAdapterCableDefinition, + getCableDefinition, + parseCableString, + parseConnectorString, + stringifyCableDefinition, + stringifyConnector, +} from "../lib" + +test("adaptercable composes independently named mating interfaces", () => { + for (const pinCount of [1, 3]) + for (const genderB of ["male", "female"] as const) { + const prefix = `bullet${pinCount === 1 ? "" : pinCount}` + const a = `${prefix}_d3.5mm_gfemale` + const b = `${prefix}_d4mm_g${genderB}` + const connectorA = parseConnectorString(a) + const connectorB = parseConnectorString(b) + const definition = getAdapterCableDefinition({ connectorA, connectorB }) + const text = `adaptercable_a(${a})_b(${b})` + expect(parseCableString(text)).toEqual(definition) + expect(parseCableString(`adaptercable_b(${b})_a(${a})`)).toEqual( + definition, + ) + expect( + getCableDefinition({ + standard: "adaptercable", + connectorA, + connectorB, + }), + ).toEqual(definition) + expect(stringifyCableDefinition(definition)).toBe(text) + expect(stringifyCableDefinition(definition, { useShorthand: true })).toBe( + text, + ) + expect(definition.connectorA).toMatchObject({ + diameter: 3.5, + pitch: 5.5, + pinCount, + kind: "bullet_female", + }) + expect(definition.connectorB).toMatchObject({ + diameter: 4, + pitch: 6, + pinCount, + kind: `bullet_${genderB}`, + }) + expect(stringifyConnector(connectorA)).toBe(a) + if (pinCount === 3) { + expect(definition.crossSection).toMatchObject({ + kind: "wire_bundle", + wirePitch: 6, + }) + if (definition.crossSection.kind !== "wire_bundle") + throw new Error("Expected three insulated wires") + expect(definition.crossSection.wires).toHaveLength(3) + } + } +}) + +test("mixed connector families compose through the same generic API", () => { + for (const [a, b, count] of [ + ["bullet3_d3.5mm_gfemale", "jst_ph_pins3", 3], + ["jst_sh_pins4", "jst_ph_pins4", 4], + ] as const) { + for (const [endA, endB] of [ + [a, b], + [b, a], + ]) { + const text = `adaptercable_a(${endA})_b(${endB})` + const definition = parseCableString(text) + expect(definition.standard).toBe("adaptercable") + expect(definition.connectorA).toEqual(parseConnectorString(endA!)) + expect(definition.connectorB).toEqual(parseConnectorString(endB!)) + expect(stringifyCableDefinition(definition)).toBe(text) + if (definition.crossSection.kind !== "wire_bundle") + throw new Error("Expected wire bundle") + expect(definition.crossSection.wires).toHaveLength(count) + expect(cableDefinitionSchema.parse(definition)).toEqual(definition) + } + } + expect( + parseCableString("adaptercable_a(nema_5_15p)_b(iec_c13)").connectorB.kind, + ).toBe("iec_c13") + expect( + parseCableString("adaptercable_a(usb_c)_b(usb_c)").crossSection.kind, + ).toBe("round_jacket") + const same = parseCableString( + "adaptercable_a(bullet3_d3.5mm_gfemale)_b(bullet3_d3.5mm_gmale)", + ) + const shorthand = stringifyCableDefinition(same, { useShorthand: true }) + expect(shorthand).toBe("bullet3_d3.5mm_afemale_bmale") + expect(parseCableString(shorthand).connectorA).toEqual(same.connectorA) +}) + +test("ambiguous ends, old diameter modifiers, malformed composition and incompatible counts fail", () => { + for (const text of [ + "bullet3_da3.5mm_db4mm_afemale_bfemale", + "bullet3_d3.5mm_db4mm", + "adaptercable_a(bullet3_d3.5mm_gfemale)", + "adaptercable_a(jst_ph_pins3)_a(jst_sh_pins3)", + "adaptercable_(jst_ph_pins3)_(jst_sh_pins3)", + "adaptercable_a(jst_ph_pins3)_b(jst_sh_pins4)", + "adaptercable_a(bullet3_d3.5mm_gfemale)_b(bullet_d4mm_gfemale)", + "adaptercable_a(bullet3_d3.5mm_afemale)_b(bullet3_d4mm_gfemale)", + "adaptercable_a(bullet3_d3.5mm)_b(bullet3_d4mm_gfemale)", + "adaptercable_a(bullet3_d3.5mm_gfemale_gmale)_b(bullet3_d4mm_gfemale)", + "adaptercable_a(bullet3_d3.5mm_gfemale)_b(bullet3_d7mm_gfemale)", + "adaptercable_a(jst_ph_pins3)_b(jst_sh_pins3)_length100mm", + "adaptercable_a(jst_ph_pins3)_b(jst_sh_pins3)_b(jst_sh_pins3)", + "adaptercable_a(adaptercable_a(usb_c)_b(usb_c))_b(usb_c)", + "adaptercable_a()_b(usb_c)", + "adaptercable_a(usb_c_b(usb_c)", + ]) + expect(() => parseCableString(text)).toThrow() + expect( + cableInputSchema.safeParse({ + standard: "bullet", + diameterA: 3.5, + diameterB: 4, + }).success, + ).toBe(false) + const connectorA = parseConnectorString("bullet3_d3.5mm_gfemale") + const connectorB = parseConnectorString("jst_ph_pins3") + expect(() => + getAdapterCableDefinition({ + connectorA, + connectorB, + crossSection: { + kind: "wire_bundle", + wirePitch: 6, + wires: Array.from({ length: 3 }, () => ({ + diameter: 3, + color: "#263449", + })), + }, + }), + ).toThrow() +}) diff --git a/tests/bullet-adapter-snapshots.test.ts b/tests/bullet-adapter-snapshots.test.ts new file mode 100644 index 0000000..8f4118f --- /dev/null +++ b/tests/bullet-adapter-snapshots.test.ts @@ -0,0 +1,14 @@ +import { test } from "bun:test" +import { renderBulletCatalog } from "./fixtures/render-cable-snapshots" +import { bulletAdapterExamples } from "../pages/bullet-adapter-examples" +import { expectPngSnapshot } from "./fixtures/expect-png-snapshot" + +test("generic adapters show independent ends and wire fanout", async () => { + await expectPngSnapshot({ + name: "bullet-adapter-cables", + png: await renderBulletCatalog( + bulletAdapterExamples, + "Adapter cables / independently specified connectors", + ), + }) +}, 30000) diff --git a/tests/bullet-connectors.test.ts b/tests/bullet-connectors.test.ts index fa65c50..efc0f51 100644 --- a/tests/bullet-connectors.test.ts +++ b/tests/bullet-connectors.test.ts @@ -87,14 +87,7 @@ test("positional, duplicate, unknown and invalid parameters fail", () => { ).toBe(false) } const definition = getCableDefinition({ standard: "bullet", diameter: 4 }) - const other = getCableDefinition({ standard: "bullet", diameter: 3.5 }) const usb = getCableDefinition({ standard: "usb_c" }) - expect( - cableDefinitionSchema.safeParse({ - ...definition, - connectorB: other.connectorB, - }).success, - ).toBe(false) expect( cableDefinitionSchema.safeParse({ ...definition, diff --git a/tests/fixtures/render-cable-snapshots.ts b/tests/fixtures/render-cable-snapshots.ts index 15ce7a9..cccd35f 100644 --- a/tests/fixtures/render-cable-snapshots.ts +++ b/tests/fixtures/render-cable-snapshots.ts @@ -196,33 +196,60 @@ export async function renderCableCatalog(): Promise { return compose({ width, height: 1890, content }) } -export async function renderBulletCatalog(): Promise { +export async function renderBulletCatalog( + examples = bulletCableExamples, + title = "Bullet connectors / single and grouped cable meshes", +): Promise { const width = 1720 const rowHeight = 440 const content = [ label({ - text: "Bullet connectors / single and grouped cable meshes", + text: title, x: 28, y: 44, size: 28, }), ] - for (const [index, example] of bulletCableExamples.entries()) { + for (const [index, example] of examples.entries()) { const y = 82 + index * rowHeight content.push( ``, label({ text: example.title, x: 40, y: y + 35, size: 23 }), - ``, + ``, ...[ "const definition =", - ` parseCableString("${example.cableString}")`, + ...(example.cableString.startsWith("adaptercable_") + ? [ + " parseCableString(", + ' "adaptercable_" +', + ...example.cableString + .slice("adaptercable_".length) + .split("_b(") + .map((part, index) => + index === 0 ? ` "${part}_" +` : ` "b(${part}"`, + ), + " )", + ] + : example.cableString.length > 24 + ? [" parseCableString(", ` "${example.cableString}"`, " )"] + : [` parseCableString("${example.cableString}")`]), "", - "const meshes = createCableMeshes({", + ...(example.cable.standard === "adaptercable" + ? ["const meshes =", " createCableMeshes({"] + : ["const meshes = createCableMeshes({"]), " definition,", " path: resolvedPath,", "})", ].map((text, line) => - label({ text, x: 48, y: y + 94 + line * 25, size: 17 }), + label({ + text, + x: 48, + y: + y + + 94 + + line * (example.cable.standard === "adaptercable" ? 23 : 25), + size: 17, + }), ), label({ text: "CABLE ASSEMBLY / ISOMETRIC", @@ -252,6 +279,7 @@ export async function renderBulletCatalog(): Promise { y: y + 88, width: example.cable.standard === "us_mains" || + example.cable.standard === "adaptercable" || example.cable.standard === "bullet" ? 230 : 460, @@ -262,6 +290,7 @@ export async function renderBulletCatalog(): Promise { ) if ( example.cable.standard === "us_mains" || + example.cable.standard === "adaptercable" || example.cable.standard === "bullet" ) content.push( @@ -282,13 +311,13 @@ export async function renderBulletCatalog(): Promise { label({ text: "Actual indexed meshes / supplied fixture paths / routing and sagging remain separate", x: 28, - y: 82 + bulletCableExamples.length * rowHeight + 20, + y: 82 + examples.length * rowHeight + 20, size: 17, }), ) return compose({ width, - height: 82 + bulletCableExamples.length * rowHeight + 50, + height: 82 + examples.length * rowHeight + 50, content, }) }