diff --git a/README.md b/README.md index 587c4df..a2fb8ac 100644 --- a/README.md +++ b/README.md @@ -9,26 +9,26 @@ Wire harness drawings from JSON. Design harnesses as `*.harness.json` files (by ## Quickstart -```bash -npm install && npm run build +No install needed — run everything with `npx`: +```bash # live-preview viewer over a folder of harness files -node packages/cli/dist/index.js dev ./examples +npx almond-harness-studio dev ./harnesses # validate -node packages/cli/dist/index.js validate examples/*.harness.json +npx almond-harness-studio validate my-harness.harness.json # pull distributor data (MPN, photo, price) for every referenced part -node packages/cli/dist/index.js parts fetch examples/branched-battery-pigtail.harness.json +npx almond-harness-studio parts fetch my-harness.harness.json # headless vector PDF export (no browser needed) -node packages/cli/dist/index.js export examples/branched-battery-pigtail.harness.json -o harness.pdf +npx almond-harness-studio export my-harness.harness.json -o harness.pdf # wiring table + BOM as CSV -node packages/cli/dist/index.js tables examples/branched-battery-pigtail.harness.json +npx almond-harness-studio tables my-harness.harness.json ``` -Once the packages are published to npm, the same commands run anywhere as `npx almond-harness-studio ` — no clone needed. +Or install globally with `npm install -g almond-harness-studio`. ## The format in 30 seconds @@ -50,16 +50,16 @@ Components are always real, orderable parts referenced by distributor part numbe } ``` -Supported today: multi-branch harnesses, twisted pairs and shielded multicore cables (`wireGroups`), ring/spade/ferrule/quick-connect/tinned/bare/solder-cup/pin terminations, splices, inline diodes and resistors (flyback diodes, pull resistors), jumper (loopback) wires, connector crimp contacts and hardware (locks/boots/backshells), heatshrink / PET braid / split loom / spiral wrap coverings, striped wire colors, per-node layout overrides, and CSV wiring-table/BOM export (`tables`). +Supported today: multi-branch harnesses, twisted pairs and shielded multicore cables (`wireGroups`), ring/spade/ferrule/quick-connect/tinned/bare/solder-cup/pin terminations, splices, inline diodes and resistors (flyback diodes, pull resistors), jumper (loopback) wires, connector crimp contacts and hardware (locks/boots/backshells), connector assembly details (`face`: real pin pattern with wire colors and bend direction), heatshrink / PET braid / split loom / spiral wrap coverings, striped wire colors, per-node layout overrides, and CSV wiring-table/BOM export (`tables`). ### Part sourcing `parts fetch` resolves every `part` reference against the distributor and embeds the record under the file's top-level `parts` object — drawings then render offline with product photos and a sourced BOM. LCSC needs no API key; Mouser and Digi-Key keys are yours: ```bash -node packages/cli/dist/index.js config set mouser.apiKey # mouser.com/api-hub -node packages/cli/dist/index.js config set digikey.clientId # developer.digikey.com -node packages/cli/dist/index.js config set digikey.clientSecret +npx almond-harness-studio config set mouser.apiKey # mouser.com/api-hub +npx almond-harness-studio config set digikey.clientId # developer.digikey.com +npx almond-harness-studio config set digikey.clientSecret ``` Keys live in `~/.config/almond-harness-studio/config.json` (or `MOUSER_API_KEY`, `DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET` env vars). The viewer also has an "API keys…" dialog — keys stay in the browser's localStorage and part lookups go through your local dev server. @@ -112,6 +112,7 @@ No environment variables or serverless functions are needed — the viewer is fu ## Development ```bash +npm install npm run dev # Vite dev server with HMR, API backed by ./examples npm run typecheck npm run build @@ -120,15 +121,6 @@ npm run check:examples # validates all example harnesses Point the dev viewer at a private data folder: `ALMOND_DATA_DIR=/path/to/harnesses npm run dev`. -### Releasing to npm - -Three packages ship together, same version: `@almond-harness-studio/core`, `@almond-harness-studio/app`, and the CLI `almond-harness-studio` (which bundles the viewer for `dev`). From a clean checkout: - -```bash -npm ci && npm run build && npm run check:examples -npm publish -w @almond-harness-studio/core -w @almond-harness-studio/app -w almond-harness-studio -``` - ## License MIT diff --git a/examples/branched-battery-pigtail.harness.json b/examples/branched-battery-pigtail.harness.json index 2883957..b1151c5 100644 --- a/examples/branched-battery-pigtail.harness.json +++ b/examples/branched-battery-pigtail.harness.json @@ -16,6 +16,17 @@ "vendor": "lcsc", "number": "C30170181" }, + "face": { + "view": "wire-side", + "wireBend": "down", + "note": "PCB PROVIDED BY CUSTOMER (NOT IN BOM)", + "pins": [ + { "pin": "1", "x": 0, "y": 0, "size": 1.7 }, + { "pin": "2", "x": 10, "y": 0, "size": 1.7 }, + { "pin": "S1", "x": 15.5, "y": 3.2, "size": 0.9 }, + { "pin": "S2", "x": 15.5, "y": -3.2, "size": 0.9 } + ] + }, "pins": [ { "id": "1", diff --git a/package-lock.json b/package-lock.json index dec2abf..c2f730e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,11 @@ "typescript": "^5.9.2" } }, - "node_modules/@almond-harness-studio/app": { + "node_modules/@almond-bot/harness-studio-app": { "resolved": "packages/app", "link": true }, - "node_modules/@almond-harness-studio/core": { + "node_modules/@almond-bot/harness-studio-core": { "resolved": "packages/core", "link": true }, @@ -2495,11 +2495,11 @@ "license": "ISC" }, "packages/app": { - "name": "@almond-harness-studio/app", - "version": "0.1.0", + "name": "@almond-bot/harness-studio-app", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@almond-harness-studio/core": "0.1.0", + "@almond-bot/harness-studio-core": "0.1.1", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/inter": "^5.2.8", "jspdf": "^3.0.2", @@ -2517,11 +2517,11 @@ }, "packages/cli": { "name": "almond-harness-studio", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@almond-harness-studio/app": "0.1.0", - "@almond-harness-studio/core": "0.1.0", + "@almond-bot/harness-studio-app": "0.1.1", + "@almond-bot/harness-studio-core": "0.1.1", "chokidar": "^4.0.3", "commander": "^14.0.0", "pdfkit": "^0.17.1", @@ -2536,8 +2536,8 @@ } }, "packages/core": { - "name": "@almond-harness-studio/core", - "version": "0.1.0", + "name": "@almond-bot/harness-studio-core", + "version": "0.1.1", "license": "MIT", "dependencies": { "ajv": "^8.17.1" diff --git a/package.json b/package.json index 927ee3a..4764d85 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "packages/*" ], "scripts": { - "build": "npm run build -w @almond-harness-studio/core -w @almond-harness-studio/app -w almond-harness-studio", - "dev": "npm run dev -w @almond-harness-studio/app", + "build": "npm run build -w @almond-bot/harness-studio-core -w @almond-bot/harness-studio-app -w almond-harness-studio", + "dev": "npm run dev -w @almond-bot/harness-studio-app", "check:examples": "node packages/cli/dist/index.js validate examples/*.harness.json skill/examples/*.harness.json", "typecheck": "tsc -b packages/core packages/cli && tsc --noEmit -p packages/app" }, diff --git a/packages/app/README.md b/packages/app/README.md new file mode 100644 index 0000000..954a84b --- /dev/null +++ b/packages/app/README.md @@ -0,0 +1,9 @@ +# @almond-bot/harness-studio-app + +Prebuilt viewer app for [almond-harness-studio](https://www.npmjs.com/package/almond-harness-studio) wire harness drawings. This package ships the static `dist/` bundle that the CLI serves for its `dev` live-preview command — you normally don't install it directly. + +```bash +npx almond-harness-studio dev ./harnesses +``` + +Full documentation: [github.com/almond-bot/harness-studio](https://github.com/almond-bot/harness-studio) diff --git a/packages/app/package.json b/packages/app/package.json index fd6a139..5e305df 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { - "name": "@almond-harness-studio/app", - "version": "0.1.0", + "name": "@almond-bot/harness-studio-app", + "version": "0.1.1", "description": "Viewer app for almond-harness-studio wire harness drawings", "license": "MIT", "type": "module", @@ -21,7 +21,7 @@ "preview": "vite preview" }, "dependencies": { - "@almond-harness-studio/core": "0.1.0", + "@almond-bot/harness-studio-core": "0.1.1", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/inter": "^5.2.8", "jspdf": "^3.0.2", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 4383c5f..6368f39 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { collectPartRefs, partKey } from "@almond-harness-studio/core"; +import { collectPartRefs, partKey } from "@almond-bot/harness-studio-core"; import { Preview } from "./Preview"; import { fetchHarnessFile, diff --git a/packages/app/src/useHarness.ts b/packages/app/src/useHarness.ts index a5e5bdc..8721180 100644 --- a/packages/app/src/useHarness.ts +++ b/packages/app/src/useHarness.ts @@ -4,7 +4,7 @@ import { renderHarnessSvg, type Harness, type ValidationIssue, -} from "@almond-harness-studio/core"; +} from "@almond-bot/harness-studio-core"; export interface LoadedHarness { sourceName: string; diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 9e0f2d2..51fdbe8 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -13,7 +13,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], "paths": { - "@almond-harness-studio/core": ["../core/src/index.ts"] + "@almond-bot/harness-studio-core": ["../core/src/index.ts"] } }, "include": ["src", "vite.config.ts"] diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index e4ca76d..afc5352 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ plugins: [react(), harnessApi()], resolve: { alias: { - "@almond-harness-studio/core": path.resolve(here, "../core/src/index.ts"), + "@almond-bot/harness-studio-core": path.resolve(here, "../core/src/index.ts"), }, }, }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 878f9c8..96d5d68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "almond-harness-studio", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI for almond-harness-studio: live-preview server, validation, and headless PDF export of wire harness drawings", "license": "MIT", "type": "module", @@ -33,11 +33,13 @@ ], "scripts": { "build": "tsc -b", - "dev": "tsc -b --watch" + "dev": "tsc -b --watch", + "prepack": "cp ../../README.md ./README.md", + "postpack": "rm -f ./README.md" }, "dependencies": { - "@almond-harness-studio/core": "0.1.0", - "@almond-harness-studio/app": "0.1.0", + "@almond-bot/harness-studio-core": "0.1.1", + "@almond-bot/harness-studio-app": "0.1.1", "chokidar": "^4.0.3", "commander": "^14.0.0", "pdfkit": "^0.17.1", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7a3c5cb..e6b470c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,15 +15,18 @@ import { buildWireList, type Harness, type PartsCache, -} from "@almond-harness-studio/core"; +} from "@almond-bot/harness-studio-core"; import { createApiHandler } from "./middleware.js"; import { CONFIG_PATH, loadConfig, resolvePart, saveConfigValue } from "./vendors.js"; +const require = createRequire(import.meta.url); +const { version: cliVersion } = require("../package.json") as { version: string }; + const program = new Command(); program .name("almond-harness-studio") .description("Wire harness drawings from JSON: live preview, validation, and PDF export") - .version("0.1.0"); + .version(cliVersion); function loadAndValidate(file: string, raw: string): { harness?: Harness; ok: boolean } { let data: unknown; @@ -273,8 +276,7 @@ const MIME: Record = { function findAppDist(): string | null { try { - const require = createRequire(import.meta.url); - const appPkg = require.resolve("@almond-harness-studio/app/package.json"); + const appPkg = require.resolve("@almond-bot/harness-studio-app/package.json"); return path.join(path.dirname(appPkg), "dist"); } catch { return null; diff --git a/packages/cli/src/middleware.ts b/packages/cli/src/middleware.ts index 52cf085..e38c8c4 100644 --- a/packages/cli/src/middleware.ts +++ b/packages/cli/src/middleware.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { collectPartRefs, partKey, type Harness, type PartsCache } from "@almond-harness-studio/core"; +import { collectPartRefs, partKey, type Harness, type PartsCache } from "@almond-bot/harness-studio-core"; import { loadConfig, resolvePart, type VendorConfig } from "./vendors.js"; type Next = () => void; diff --git a/packages/cli/src/vendors.ts b/packages/cli/src/vendors.ts index 54b3abf..487fbc1 100644 --- a/packages/cli/src/vendors.ts +++ b/packages/cli/src/vendors.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { PartRef, ResolvedPart } from "@almond-harness-studio/core"; +import type { PartRef, ResolvedPart } from "@almond-bot/harness-studio-core"; /** * Distributor API clients. Parts are always sourced from a real vendor: diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..1148e0c --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,11 @@ +# @almond-bot/harness-studio-core + +Schema, validation, tree layout, and pure SVG-string rendering for [almond-harness-studio](https://www.npmjs.com/package/almond-harness-studio) wire harness drawings. No React — runs in Node and the browser. + +Most users want the CLI instead: + +```bash +npx almond-harness-studio dev ./harnesses +``` + +Full documentation: [github.com/almond-bot/harness-studio](https://github.com/almond-bot/harness-studio) diff --git a/packages/core/package.json b/packages/core/package.json index 2a0a939..efbc851 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { - "name": "@almond-harness-studio/core", - "version": "0.1.0", + "name": "@almond-bot/harness-studio-core", + "version": "0.1.1", "description": "Schema, validation, layout, and SVG rendering for almond-harness-studio wire harness drawings", "license": "MIT", "type": "module", diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 85d96ea..9503cca 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -1,4 +1,5 @@ import type { + ConnectorNode, Harness, InlineComponentNode, PartsCache, @@ -157,6 +158,32 @@ function fitText(value: string, widthPx: number, fontSize: number): string { return value.slice(0, Math.max(1, maxChars - 1)).trimEnd() + "…"; } +/** Word-wrap to fit a pixel width; words longer than a line are hard-broken. */ +function wrapText(value: string, widthPx: number, fontSize: number): string[] { + const maxChars = Math.max(8, Math.floor(widthPx / (fontSize * 0.56))); + const lines: string[] = []; + let current = ""; + for (const word of value.split(/\s+/)) { + let w = word; + while (w.length > maxChars) { + if (current) { + lines.push(current); + current = ""; + } + lines.push(w.slice(0, maxChars)); + w = w.slice(maxChars); + } + if (!current) current = w; + else if (current.length + 1 + w.length <= maxChars) current += " " + w; + else { + lines.push(current); + current = w; + } + } + if (current) lines.push(current); + return lines.length > 0 ? lines : [""]; +} + interface Column { title: string; width: number; @@ -437,6 +464,168 @@ function renderPartsGallery( return { svg: parts.join("\n"), height: rows * (cardH + gap) }; } +/** + * Assembly detail card for a connector: the product photo plus an authored + * face view of the real pin pattern, each cavity filled in its wire color, + * with an arrow showing which way the wires bend in that view's frame — the + * references an operator needs to orient the part in hand. + */ +function renderFaceDetails( + harness: Harness, + partsCache: PartsCache, + x: number, + y: number, + maxW: number +): { svg: string; height: number } { + const detailed = harness.nodes.filter( + (n): n is ConnectorNode => n.kind === "connector" && n.face !== undefined + ); + if (detailed.length === 0) return { svg: "", height: 0 }; + + const cardH = 150; + const gap = 10; + const parts: string[] = []; + let cx = x; + let cy = y; + + const wireAt = (nodeId: string, pinId: string) => + harness.wires.find((w) => w.from === `${nodeId}.${pinId}` || w.to === `${nodeId}.${pinId}`); + + for (const node of detailed) { + const face = node.face!; + const resolved = resolvedFor(node, partsCache); + const hasPhoto = Boolean(resolved?.image); + const faceSlot = 150; + const cardW = 16 + (hasPhoto ? 74 : 0) + faceSlot; + if (cx > x && cx + cardW > x + maxW) { + cx = x; + cy += cardH + gap; + } + + parts.push( + `` + ); + parts.push(text(cx + 6, cy + 13, `ASSEMBLY DETAIL — ${node.id}`, { size: 8, weight: "bold" })); + + // Caption states the requirement in words; the diagram shows it + if (face.wireBend) { + parts.push( + text(cx + 6, cy + 24, fitText(`WIRES BEND ${face.wireBend.toUpperCase()} IN THIS VIEW`, cardW - 12, 5.5), { + size: 5.5, + fill: T.textSoft, + }) + ); + } + + let px0 = cx + 8; + + // Product photo panel: the real part the assembler orients against + if (resolved?.image) { + parts.push( + ``, + ``, + text(px0 + 32, cy + 102, fitText(resolved.mpn, 60, 6), { size: 6, anchor: "middle", fill: T.textSoft }) + ); + px0 += 74; + } + + // Face view: the authored pin pattern (the operator's orientation + // reference), each cavity filled with its wire color, and the bend + // direction drawn in this view's frame + { + const bend = face.wireBend; + const minX = Math.min(...face.pins.map((p) => p.x)); + const maxX = Math.max(...face.pins.map((p) => p.x)); + const minY = Math.min(...face.pins.map((p) => p.y)); + const maxY = Math.max(...face.pins.map((p) => p.y)); + const scale = Math.min( + 60 / Math.max(maxX - minX, 1e-6), + 44 / Math.max(maxY - minY, 1e-6), + 30 + ); + const cmx = px0 + faceSlot / 2; + const cmy = cy + 74 + (bend === "up" ? 8 : bend === "down" ? -8 : 0); + const pos = face.pins.map((fp) => ({ + fp, + r: 5.5 * (fp.size ?? 1), + px: cmx + (fp.x - (minX + maxX) / 2) * scale, + py: cmy - (fp.y - (minY + maxY) / 2) * scale, + })); + + // Connector body outline around the pin pattern + const fbx0 = Math.min(...pos.map((m) => m.px - m.r)) - 8; + const fbx1 = Math.max(...pos.map((m) => m.px + m.r)) + 8; + const fby0 = Math.min(...pos.map((m) => m.py - m.r)) - 8; + const fby1 = Math.max(...pos.map((m) => m.py + m.r)) + 8; + parts.push( + `` + ); + + for (const { fp, r, px, py } of pos) { + const wire = wireAt(node.id, fp.pin); + const color = wire ? parseWireColor(wire.color) : undefined; + const fill = color ? color.base : T.panel; + const stroke = color && T.outlinedWires.includes(color.base) ? T.wireOutline : T.ink; + parts.push( + `` + ); + if (color?.stripe) { + parts.push( + `` + ); + } + parts.push(text(px, py + r + 7, fp.pin, { size: 5, anchor: "middle" })); + } + + // Bend arrow: bold, outside the body, in this view's frame + if (bend) { + const bmx = (fbx0 + fbx1) / 2; + const bmy = (fby0 + fby1) / 2; + const dirs = { + up: { x0: bmx, y0: fby0 - 4, dx: 0, dy: -1 }, + down: { x0: bmx, y0: fby1 + 4, dx: 0, dy: 1 }, + left: { x0: fbx0 - 4, y0: bmy, dx: -1, dy: 0 }, + right: { x0: fbx1 + 4, y0: bmy, dx: 1, dy: 0 }, + } as const; + const d = dirs[bend]; + const ex = d.x0 + d.dx * 16; + const ey = d.y0 + d.dy * 16; + parts.push( + ``, + `` + ); + if (bend === "up" || bend === "down") { + parts.push(text(bmx + 9, (d.y0 + ey) / 2 + 2, "WIRE BEND", { size: 5, fill: T.textSoft })); + } else { + parts.push(text((d.x0 + ex) / 2, bmy - 9, "WIRE BEND", { size: 5, anchor: "middle", fill: T.textSoft })); + } + } + + parts.push( + text(cmx, cy + cardH - 18, `VIEW FROM ${face.view === "mating-side" ? "MATING SIDE" : "WIRE SIDE"}`, { + size: 5, + anchor: "middle", + fill: T.textDim, + }) + ); + } + + if (face.note) { + parts.push( + text(cx + cardW / 2, cy + cardH - 7, fitText(face.note, cardW - 10, 5.5), { + size: 5.5, + anchor: "middle", + fill: T.textSoft, + }) + ); + } + + cx += cardW + gap; + } + + return { svg: parts.join("\n"), height: cy - y + cardH + gap }; +} + function renderNode(box: NodeBox, layout: LayoutResult, partsCache: PartsCache): string { const node = box.node; const parts: string[] = []; @@ -1002,15 +1191,26 @@ export function renderHarnessSvg(harness: Harness, options: RenderOptions = {}): ) ); - // Notes: between wire list and title block + // Notes: between wire list and title block, word-wrapped so they never + // spill into the title block or past the frame const notes = harness.notes ?? []; + let notesH = 0; if (notes.length > 0) { const nX = wlX + wlW + 24; - let nY = frame.y + frame.h - 10 - notes.length * 13 - 16; + const noteSize = 9; + const noteW = tbX - 16 - nX; + const wrapped = notes.map((note, i) => wrapText(`${i + 1}. ${note}`, noteW, noteSize)); + const totalLines = wrapped.reduce((sum, lines) => sum + lines.length, 0); + const nY = frame.y + frame.h - 10 - totalLines * 13 - 16; + notesH = frame.y + frame.h - nY; parts.push(text(nX, nY, "NOTES:", { size: 10, weight: "bold" })); - notes.forEach((note, i) => { - parts.push(text(nX, nY + 15 + i * 13, `${i + 1}. ${note}`, { size: 9 })); - }); + let lineIdx = 0; + for (const lines of wrapped) { + lines.forEach((line, j) => { + parts.push(text(nX + (j > 0 ? 12 : 0), nY + 15 + lineIdx * 13, line, { size: noteSize })); + lineIdx++; + }); + } } // Parts gallery: pictorial views in the top-left, clear of the schematic @@ -1023,14 +1223,25 @@ export function renderHarnessSvg(harness: Harness, options: RenderOptions = {}): ); parts.push(gallery.svg); + // Assembly details: connector face views below the gallery + const faceDetails = renderFaceDetails( + harness, + partsCache, + frame.x + 10, + frame.y + 10 + gallery.height, + bomX - frame.x - 40 + ); + parts.push(faceDetails.svg); + const topBand = gallery.height + faceDetails.height; + // Drawing area: center on the sheet (below the gallery, above the bottom // band); if the drawing would collide with the BOM in the top-right, fall // back to the region left of it. - const bottomBand = Math.max(wlH, tbH) + 24; + const bottomBand = Math.max(wlH, tbH, notesH) + 24; const b = { ...layout.bounds }; const pad = 30; - const availY = frame.y + 20 + gallery.height; - const availH = frame.h - bottomBand - 40 - gallery.height; + const availY = frame.y + 20 + topBand; + const availH = frame.h - bottomBand - 40 - topBand; let scale = Math.min((frame.w - 40) / (b.width + pad * 2), availH / (b.height + pad * 2), 1.25); let tx = frame.x + (frame.w - b.width * scale) / 2 - b.x * scale; let ty = availY + (availH - b.height * scale) / 2 - b.y * scale; diff --git a/packages/core/src/schema.json b/packages/core/src/schema.json index f927fa9..c859f6d 100644 --- a/packages/core/src/schema.json +++ b/packages/core/src/schema.json @@ -41,6 +41,31 @@ "part": { "$ref": "#/definitions/part" }, "contacts": { "$ref": "#/definitions/part" }, "hardware": { "type": "array", "items": { "$ref": "#/definitions/part" } }, + "face": { + "type": "object", + "required": ["pins"], + "additionalProperties": false, + "properties": { + "view": { "enum": ["wire-side", "mating-side"] }, + "wireBend": { "enum": ["up", "down", "left", "right"] }, + "note": { "type": "string" }, + "pins": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["pin", "x", "y"], + "additionalProperties": false, + "properties": { + "pin": { "type": "string", "minLength": 1 }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "size": { "type": "number", "exclusiveMinimum": 0 } + } + } + } + } + }, "pins": { "type": "array", "minItems": 1, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 65168cd..09949f5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -58,6 +58,31 @@ export interface Position { y: number; } +/** Pin position on the connector face, in arbitrary units (+y is up; the view scales to fit). */ +export interface FacePin { + /** Pin id from the connector's `pins` */ + pin: string; + x: number; + y: number; + /** Relative marker size, 1 = standard (e.g. 1.7 for power pins, 0.9 for signal pins) */ + size?: number; +} + +/** + * Face view of the connector: the real pin pattern (so an operator can orient + * the part in hand), each cavity colored by its attached wire, and the + * direction wires bend in this view's frame. + */ +export interface ConnectorFace { + /** Which face the view shows (default "wire-side" — where the operator solders/crimps) */ + view?: "wire-side" | "mating-side"; + pins: FacePin[]; + /** Direction the wires bend after leaving the connector, in this view's frame */ + wireBend?: "up" | "down" | "left" | "right"; + /** Extra caption under the detail, e.g. "PCB PROVIDED BY CUSTOMER" */ + note?: string; +} + export interface ConnectorNode { id: string; kind: "connector"; @@ -68,6 +93,8 @@ export interface ConnectorNode { contacts?: PartRef; /** Locks, boots, backshells, dust covers — one BOM line each */ hardware?: PartRef[]; + /** Pin-pattern face view with wire colors and bend direction */ + face?: ConnectorFace; position?: Position; } diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index ac32e34..62f71b7 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -200,6 +200,20 @@ export function validateHarness(data: unknown): ValidationResult { } }); + // Face views reference the connector's own pins + harness.nodes.forEach((node, i) => { + if (node.kind !== "connector" || !node.face) return; + const pinIds = new Set(node.pins.map((p) => p.id)); + node.face.pins.forEach((fp, j) => { + if (!pinIds.has(fp.pin)) { + errors.push({ + path: `/nodes/${i}/face/pins/${j}`, + message: `face view of "${node.id}" references unknown pin "${fp.pin}" (pins: ${node.pins.map((p) => p.id).join(", ")})`, + }); + } + }); + }); + if (harness.layout?.root && !nodeById.has(harness.layout.root)) { errors.push({ path: "/layout/root", diff --git a/skill/SKILL.md b/skill/SKILL.md index a10dd9c..e440b3b 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -107,4 +107,5 @@ Key rules: - Prefer LCSC part numbers when the user has no vendor preference — lookups need no API key. - Put manufacturing requirements in `notes` in uppercase (tolerance, workmanship spec, twist lay, label requirements). - Use `wireGroups` with `twisted: true` for differential/CAN/I2C pairs. +- When assembly orientation matters (wires must bend a specific direction relative to an asymmetric connector, e.g. a right-angle or PCB-soldered part), add `face` to that connector: transcribe the pin pattern positions from the part's datasheet and set `wireBend`. This renders an ASSEMBLY DETAIL card (product photo + pin-pattern face view with wire colors + bend arrow) that the operator assembles from. See [reference.md](reference.md) for the fields. - For full field-by-field documentation, see [reference.md](reference.md). For complete worked examples, see [examples/](examples/). diff --git a/skill/examples/branched-battery-pigtail.harness.json b/skill/examples/branched-battery-pigtail.harness.json index 2883957..b1151c5 100644 --- a/skill/examples/branched-battery-pigtail.harness.json +++ b/skill/examples/branched-battery-pigtail.harness.json @@ -16,6 +16,17 @@ "vendor": "lcsc", "number": "C30170181" }, + "face": { + "view": "wire-side", + "wireBend": "down", + "note": "PCB PROVIDED BY CUSTOMER (NOT IN BOM)", + "pins": [ + { "pin": "1", "x": 0, "y": 0, "size": 1.7 }, + { "pin": "2", "x": 10, "y": 0, "size": 1.7 }, + { "pin": "S1", "x": 15.5, "y": 3.2, "size": 0.9 }, + { "pin": "S2", "x": 15.5, "y": -3.2, "size": 0.9 } + ] + }, "pins": [ { "id": "1", diff --git a/skill/reference.md b/skill/reference.md index 01b6031..5189475 100644 --- a/skill/reference.md +++ b/skill/reference.md @@ -47,6 +47,22 @@ All nodes: `id` (unique, `[A-Za-z0-9_-]+`), `kind`, optional `position` `{x, y}` - `pins` (required): array of `{ "id": "1", "label": "BAT+" }`. Pin ids are strings — `"S1"`, `"A"` are fine. - `contacts`: crimp contact part; BOM quantity = number of wired cavities - `hardware`: array of parts for locks, boots, backshells, dust covers (one BOM line each) +- `face`: renders an "ASSEMBLY DETAIL" card (product photo + face view of the real pin pattern, each cavity filled in its wire's color) — this is how an operator orients an asymmetric connector in hand and sees which way the wires bend. Author pin positions from the part's datasheet drawing: + +```json +{ "view": "wire-side", "wireBend": "down", "note": "PCB PROVIDED BY CUSTOMER", + "pins": [ + { "pin": "1", "x": 0, "y": 0, "size": 1.7 }, + { "pin": "2", "x": 10, "y": 0, "size": 1.7 }, + { "pin": "S1", "x": 15.5, "y": 3.2, "size": 0.9 }, + { "pin": "S2", "x": 15.5, "y": -3.2, "size": 0.9 } + ] } +``` + + - `pins` (required): one entry per drawn cavity; `pin` references the connector's pin ids, `x`/`y` are relative positions in any consistent unit (+y is up, the view auto-scales), `size` scales the marker (e.g. 1.7 for power pins, 0.9 for signal pins) + - `wireBend`: direction the wires bend after leaving the connector, in this view's frame — `up`, `down`, `left`, `right`. Drawn as a bold arrow and stated in the caption. + - `view`: `wire-side` (default — the face the operator solders/crimps at) or `mating-side`. Mind the mirror flip: the same pattern viewed from the wire side is left-right mirrored vs the mating side. + - `note`: caption under the detail. State tolerances and covering extent in top-level `notes`. ### terminal