diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..faf009e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "25" + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/.gitignore b/.gitignore index d451c57..38b19c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .claude/worktrees/ out/ +dist/ diff --git a/README.md b/README.md index bf6d95f..e3006cf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,31 @@ its own layout and drawing, so the output looks the same everywhere and works in Coming from WireViz? The format will feel familiar, and ferrule keeps compatibility where it can. > [!NOTE] -> Pre-alpha. The look is settled, and the layout spike passed: see [docs/spike](docs/spike/README.md). Parity is next. +> Pre-alpha. Wiring works: ferrule reads the WireViz subset one real project uses and draws all +> ten of its looms. Plumbing is not started. + +## Use + +```bash +npm install github:XDGFX/ferrule#v0.2.0 +npx ferrule --prepend shared.yml --output-dir diagrams src/*.yml +``` + +The arguments match WireViz's CLI where the two overlap. Each loom gives `.svg`, which +follows the viewer's light or dark mode, and `.png`, rendered dark. `--format svg` or +`--format png` writes one of the two. Adding `md`, as in `--format svg,png,md`, also writes +`.md`: a cut list of every core, with its size, length and both ends, and a pinout table +for each connector. GitHub renders both tables on a phone. + +**WireViz syntax read:** prepended files with cross-file anchors and `<<` merges; `pinlabels`, +`pincount`, `pincolors`, `loops` and `style: simple` on connectors; `colors`, `wirelabels`, +`wirecount`, `gauge` (mm² or AWG), `length` and `notes` on cables; `X.Y` and `X.` instances; pin +ranges such as `1-4`; connection sets that start or end with a cable; and `==` mates. Anything +else is ignored, or is an error if it changes what connects to what. + +**Parity check:** `npm run parity -- --dir --python ` reads every +loom with ferrule and with WireViz and fails if they disagree on any connector, connection, mate +or loop. ## Why @@ -34,8 +58,8 @@ Coming from WireViz? The format will feel familiar, and ferrule keeps compatibil - **The same bytes on every machine.** Graphviz-based tools measure text with whatever fonts happen to be installed, so the same YAML renders differently on a laptop and a CI runner. ferrule bundles its font and measures text itself, so CI can diff SVGs byte for byte. -- **Real dark mode.** SVGs carry CSS variables and `prefers-color-scheme`. PNGs come in light and - dark, because some viewers, including the GitHub mobile app, show PNGs but not SVGs. +- **Real dark mode.** SVGs carry CSS variables and `prefers-color-scheme`. PNGs are rendered + dark, because the viewer that needs them, the GitHub mobile app, shows PNGs but not SVGs. - **Wiring and plumbing match.** One format and one renderer cover both. ## What it looks like @@ -67,7 +91,10 @@ engine existed. | **Gauge** | Line weight follows conductor size, so a 50 mm² run looks like one. | | **Single-core runs** | A tag on the wire, such as `50 mm² · 1.5 m`, not a separate box. | | **Multi-core cables** | A dashed card that the cores pass straight through, each labelled on its wire. | -| **Off-sheet ends** | An arrow naming the other end, such as `→ MPPT_150_45`, instead of a wire that just stops. | +| **Junctions** | A single core that splits, or ends open, gets a dashed card like a multi-core. A core that goes nowhere ends in a short cap. | +| **Mates and loops** | Two connectors plugged together are joined by a chain of dots between their title bars. Two pins bridged on one connector loop off its side. | +| **Pin colours** | A small chip before the pin's label, split for a two-colour mark. | +| **Off-sheet ends** | Planned: an arrow naming the other end, such as `→ MPPT_150_45`, instead of a wire that just stops. | | **Type** | Inter, bundled and measured in-process. | ## How it fits together @@ -79,7 +106,7 @@ engine existed. Wiring and plumbing share one format and one model, so both come out of the same layout and drawing code. PNGs go through a flattened copy of the SVG with literal colours, one per theme, -because resvg can't resolve CSS variables. +because resvg can't resolve CSS variables. The CLI writes the dark one. **Fallback:** if ELK's layouts turn out worse than Graphviz's, ferrule keeps `dot -Tjson` for layout only and still draws everything itself. That keeps the look and dark mode, but output would @@ -89,8 +116,8 @@ no longer be identical across machines. - [x] **Look.** Three style directions were built by hand and checked on a phone, and *Instrument* was chosen. - [x] **Layout spike.** Lay out a large, dense loom with elkjs and compare it with `dot -Tjson`. This is the go/no-go point. -- [ ] **Parity.** Render a full set of real looms until every one is correct, then compare them byte for byte in CI. -- [ ] **New outputs.** Pinout tables per connector in Markdown, a cut list, and gauge checks against terminal and fuse limits. +- [x] **Parity.** Render a full set of real looms until every one is correct. Their connections match WireViz's, and the project that uses them diffs the SVGs byte for byte in CI. +- [x] **New outputs.** Pinout tables per connector and a cut list, in Markdown. Gauge checks against fuse and contact limits live in the project that uses ferrule, since they don't need the renderer. - [ ] **Plumbing.** Pipe runs, fittings and tanks on the same core. ## Not planned diff --git a/package-lock.json b/package-lock.json index e1e3d4c..d016f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,33 +1,30 @@ { - "name": "155-layout-spike", - "version": "1.0.0", + "name": "ferrule", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "155-layout-spike", - "version": "1.0.0", - "license": "ISC", + "name": "ferrule", + "version": "0.2.0", "dependencies": { - "@fontsource/inter": "^5.3.0", - "@resvg/resvg-js": "^2.6.2", - "elkjs": "^0.12.0", - "opentype.js": "^2.0.0", - "yaml": "^2.9.1" + "@resvg/resvg-js": "2.6.2", + "elkjs": "0.12.0", + "fontverter": "2.0.0", + "harfbuzzjs": "1.6.2", + "inter-ui": "4.1.1", + "subset-font": "2.9.0", + "yaml": "2.9.1" + }, + "bin": { + "ferrule": "dist/cli.js" }, "devDependencies": { - "@types/node": "^26.6.3", - "@types/opentype.js": "^1.3.10", - "typescript": "^7.0.2" - } - }, - "node_modules/@fontsource/inter": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", - "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" + "@types/node": "26.6.3", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@resvg/resvg-js": { @@ -255,13 +252,6 @@ "undici-types": "~8.9.0" } }, - "node_modules/@types/opentype.js": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", - "integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -602,19 +592,73 @@ "node": ">=16.20.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/elkjs": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", "license": "EPL-2.0 OR GPL-3.0-or-later" }, - "node_modules/opentype.js": { + "node_modules/fontverter": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", - "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz", + "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "wawoff2": "^2.0.0", + "woff2sfnt-sfnt2woff": "^1.0.0" + } + }, + "node_modules/harfbuzzjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-1.6.2.tgz", + "integrity": "sha512-95c1vWuzoHjM19d5fQgPbz1wcv1pTa0UM9dZrdPgSMUEVuSwuzeVPkq6UhHHw3jLOkE9AYzzTP8XQkRBpPYpEg==", + "license": "MIT" + }, + "node_modules/inter-ui": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/inter-ui/-/inter-ui-4.1.1.tgz", + "integrity": "sha512-451h0J29HyOmA+JXgSi/6M12tL7ZCZ8arYKZUXiOXTJpJbAKqJvFh3k5SiV3x7tKe0C0KyrKUUiQIvvZ2PQDcA==", + "license": "OFL-1.1", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "bin": { - "ot": "bin/ot" + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/subset-font": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.9.0.tgz", + "integrity": "sha512-WJulQeFwbewrXIGcLJbSZFWI44fHW8gNRuPzLywfDL8w38IPRbN1JFxwG76aoohw9kcFdvjVIn2QmID1iy119A==", + "license": "BSD-3-Clause", + "dependencies": { + "fontverter": "^2.0.0", + "harfbuzzjs": "^1.6.1", + "p-limit": "^3.1.0" } }, "node_modules/typescript": { @@ -659,6 +703,28 @@ "dev": true, "license": "MIT" }, + "node_modules/wawoff2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz", + "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "woff2_compress.js": "bin/woff2_compress.js", + "woff2_decompress.js": "bin/woff2_decompress.js" + } + }, + "node_modules/woff2sfnt-sfnt2woff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz", + "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.7" + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", @@ -673,6 +739,18 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index f24774b..0db6a37 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,37 @@ { "name": "ferrule", - "version": "0.0.0", + "version": "0.2.0", "private": true, "description": "Wiring and plumbing diagrams from YAML", "type": "module", + "bin": { + "ferrule": "dist/cli.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepare": "npm run build", "typecheck": "tsc --noEmit", "test": "node --test \"test/**/*.test.ts\"", - "spike": "node spike/run.ts" + "spike": "node spike/run.ts", + "parity": "node parity/run.ts" }, "dependencies": { - "@fontsource/inter": "^5.3.0", - "@resvg/resvg-js": "^2.6.2", - "elkjs": "^0.12.0", - "opentype.js": "^2.0.0", - "yaml": "^2.9.1" + "@resvg/resvg-js": "2.6.2", + "elkjs": "0.12.0", + "fontverter": "2.0.0", + "harfbuzzjs": "1.6.2", + "inter-ui": "4.1.1", + "subset-font": "2.9.0", + "yaml": "2.9.1" }, "devDependencies": { - "@types/node": "^26.6.3", - "@types/opentype.js": "^1.3.10", - "typescript": "^7.0.2" + "@types/node": "26.6.3", + "typescript": "7.0.2" } } diff --git a/parity/run.ts b/parity/run.ts new file mode 100644 index 0000000..013329a --- /dev/null +++ b/parity/run.ts @@ -0,0 +1,82 @@ +// Parity: read every loom in a WireViz project with ferrule and with the fork it replaces, and +// check that both found the same connectors, connections, mates and loops. +// +// node parity/run.ts --dir --python [loom ...] +// +// The project directory holds shared.yml, prepended to every loom, and the looms in src/. Exits +// non-zero on any difference. This checks what is drawn, not how: the drawings need their own look. + +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { parseArgs } from "node:util"; +import type { End, Harness } from "../src/model.ts"; +import { readWireviz } from "../src/wireviz.ts"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { dir: { type: "string", default: "." }, python: { type: "string", default: "python3" } }, +}); +const dir = values.dir!; +const looms = positionals.length + ? positionals + : readdirSync(`${dir}/src`).filter((f) => f.endsWith(".yml")).map((f) => f.slice(0, -4)).sort(); + +interface Dump { + connectors: string[]; + links: [[string, string] | null, string, number, [string, string] | null][]; + mates: [string, string][]; + loops: Record; +} + +/** An autogenerated instance as `TEMPLATE.n`, as the dump names it. */ +const name = (id: string) => id.replace(/^__(.+)_(\d+)$/, "$1.$2"); +const end = (e: [string, string] | End | null) => (e == null ? "·" : Array.isArray(e) ? `${e[0]}:${e[1]}` : `${name(e.connector)}:${e.pin}`); + +function fromFerrule(h: Harness): Dump { + return { + connectors: [...new Set(h.connectors.map((c) => name(c.id)))].sort(), + links: h.links.map((l) => [l.from && [name(l.from.connector), l.from.pin], name(l.cable), l.wire, l.to && [name(l.to.connector), l.to.pin]]), + mates: h.mates.map((m) => [name(m.from), name(m.to)]), + loops: Object.fromEntries(h.connectors.filter((c) => c.loops.length).map((c) => [name(c.id), c.loops])), + }; +} + +/** Each part of a dump as a sorted list of lines, so two dumps compare as multisets. */ +function lines(d: Dump): Record { + return { + connectors: d.connectors, + links: d.links.map(([f, c, w, t]) => `${end(f)} → ${c}/${w} → ${end(t)}`).sort(), + mates: d.mates.map(([a, b]) => `${a} == ${b}`).sort(), + loops: Object.entries(d.loops).flatMap(([c, ls]) => ls.map(([a, b]) => `${c}: ${a}–${b}`)).sort(), + }; +} + +function diff(a: string[], b: string[]): string[] { + const count = new Map(); + for (const x of a) count.set(x, (count.get(x) ?? 0) + 1); + for (const x of b) count.set(x, (count.get(x) ?? 0) - 1); + return [...count].filter(([, n]) => n).map(([x, n]) => `${n > 0 ? "fork only: " : "ferrule only:"} ${x}`); +} + +const shared = readFileSync(`${dir}/shared.yml`, "utf8"); +let failed = 0; +for (const loom of looms) { + const src = `${dir}/src/${loom}.yml`; + const fork: Dump = JSON.parse( + execFileSync(values.python!, [new URL("wireviz_dump.py", import.meta.url).pathname, `${dir}/shared.yml`, src], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), + ); + const ours = lines(fromFerrule(readWireviz([shared, readFileSync(src, "utf8")], loom))); + const theirs = lines(fork); + const problems = Object.keys(ours).flatMap((k) => diff(theirs[k], ours[k]).map((d) => ` ${k}: ${d}`)); + const n = ours.links.length; + console.log(`${problems.length ? "✗" : "✓"} ${loom}: ${ours.connectors.length} connectors, ${n} connections, ${ours.mates.length} mates, ${ours.loops.length} loops`); + for (const p of problems) console.log(p); + if (problems.length) failed++; +} +if (failed) { + console.log(`\n${failed} of ${looms.length} looms differ from the fork.`); + process.exitCode = 1; +} else console.log(`\nAll ${looms.length} looms match the fork.`); diff --git a/parity/wireviz_dump.py b/parity/wireviz_dump.py new file mode 100644 index 0000000..a382621 --- /dev/null +++ b/parity/wireviz_dump.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Dump what the WireViz fork reads from a loom, as JSON, for parity/run.ts to compare against. + + python3 parity/wireviz_dump.py PREPEND LOOM + +Needs the fork that the project pins (for Hailey, `XDGFX/WireViz@hailey-v1`) importable. The +prepended file's text goes in front of the loom's, as the fork's --prepend does. +""" +import contextlib +import json +import re +import sys +from pathlib import Path + +from wireviz.wireviz import parse +from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX + +FRESH = re.compile(rf"^{AUTOGENERATED_PREFIX}(.+)_(\d+)$") + + +def name(designator): + """An autogenerated instance as `TEMPLATE.n`. Both readers number them in the same order, so + the numbers must match too: a mismatch means one made more instances than the other.""" + m = FRESH.match(designator) + return f"{m.group(1)}.{m.group(2)}" if m else designator + + +def main(prepend, loom): + text = Path(prepend).read_text() + "\n" + Path(loom).read_text() + # The fork prints its warnings to stdout, which is where the JSON goes. + with contextlib.redirect_stdout(sys.stderr): + h = parse(text, return_types="harness") + + def end(pin): + return None if pin is None else [name(pin.parent), str(pin.id)] + + links = [] + for cable in h.cables.values(): + for c in cable._connections: + links.append([end(c.from_), name(cable.designator), int(c.via.id), end(c.to)]) + mates = [[name(m.from_), name(m.to)] for m in h.mates] + loops = { + name(d): [[str(a), str(b)] for a, b in c.loops] for d, c in h.connectors.items() if c.loops + } + json.dump( + {"connectors": sorted({name(d) for d in h.connectors}), "links": links, "mates": mates, "loops": loops}, + sys.stdout, + ) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/spike/run.ts b/spike/run.ts index 2d063df..2d67e80 100644 --- a/spike/run.ts +++ b/spike/run.ts @@ -40,8 +40,8 @@ for (const loom of looms) { const t2 = performance.now(); const svgs: Record = { - elk: render(sheet, elk, `${loom}.yml · elkjs layered, orthogonal`), - dot: render(sheet, dot, `${loom}.yml · dot -Tjson, splines`), + elk: await render(sheet, elk, `${loom}.yml · elkjs layered, orthogonal`), + dot: await render(sheet, dot, `${loom}.yml · dot -Tjson, splines`), }; for (const [engine, svg] of Object.entries(svgs)) { writeFileSync(new URL(`${loom}-${engine}.svg`, outDir), svg + "\n"); @@ -55,7 +55,7 @@ for (const loom of looms) { } // Same input, fresh ELK instance: the bytes must match for a strict CI diff to work. - const again = render(sheet, await layoutElk(sheet), `${loom}.yml · elkjs layered, orthogonal`); + const again = await render(sheet, await layoutElk(sheet), `${loom}.yml · elkjs layered, orthogonal`); const stable = again === svgs.elk; report.push(section(loom, sheet.cards.length, sheet.wires.length, { elk, dot }, { elk: t1 - t0, dot: t2 - t1 }, stable, sheet)); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..6340056 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Render WireViz looms in the Instrument style. Takes the same arguments as the fork's CLI where +// the two overlap, so a project can switch by changing the command name. +// +// ferrule [--prepend FILE]... [--format svg,png,md] [--output-dir DIR] LOOM.yml... +// +// Writes .svg, which follows the viewer's light or dark mode, and .png, rendered dark. +// `md` adds .md, a cut list and a pinout for each connector, and is only written on request. + +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { parseArgs } from "node:util"; +import { Resvg } from "@resvg/resvg-js"; +import { layoutElk } from "./layout/elk.ts"; +import { writeFonts } from "./measure.ts"; +import { flatten, render } from "./render.ts"; +import { buildSheet } from "./sheet.ts"; +import { tables } from "./tables.ts"; +import { readWireviz } from "./wireviz.ts"; + +const FORMATS = ["svg", "png", "md"] as const; +type Format = (typeof FORMATS)[number]; + +/** PNGs are for phones, where the dark theme reads best and where they get zoomed. */ +const PNG_THEME = "dark"; +const PNG_ZOOM = 2; + +const USAGE = "usage: ferrule [--prepend FILE]... [--format svg,png,md] [--output-dir DIR] LOOM.yml..."; + +async function main(argv: string[]): Promise { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + prepend: { type: "string", short: "p", multiple: true, default: [] }, + format: { type: "string", short: "f", default: "svg,png" }, + "output-dir": { type: "string", short: "o" }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help || !positionals.length) { + console.error(USAGE); + return values.help ? 0 : 2; + } + const formats = values.format.split(",").map((f) => f.trim()).filter(Boolean); + const unknown = formats.filter((f) => !(FORMATS as readonly string[]).includes(f)); + if (unknown.length) { + console.error(`ferrule: unknown format ${unknown.join(", ")}; choose from ${FORMATS.join(", ")}`); + return 2; + } + + const prepended = values.prepend.map((p) => readFileSync(p, "utf8")); + let fontDir: string | null = null; + try { + for (const loom of positionals) { + const name = basename(loom).replace(/\.ya?ml$/, ""); + const out = values["output-dir"] ?? dirname(loom); + mkdirSync(out, { recursive: true }); + + const harness = readWireviz([...prepended, readFileSync(loom, "utf8")], name); + if (formats.includes("md" satisfies Format)) writeFileSync(join(out, `${name}.md`), tables(harness)); + if (!formats.some((f) => f !== "md")) { + console.log(`${loom} → ${join(out, `${name}.md`)}`); + continue; + } + const sheet = buildSheet(harness); + const svg = await render(sheet, await layoutElk(sheet)); + if (formats.includes("svg" satisfies Format)) writeFileSync(join(out, `${name}.svg`), svg + "\n"); + if (formats.includes("png" satisfies Format)) { + // resvg reads fonts only from disk, and can't apply the SVG's embedded @font-face. + fontDir ??= mkdtempSync(join(tmpdir(), "ferrule-fonts-")); + const png = new Resvg(flatten(svg, PNG_THEME), { + fitTo: { mode: "zoom", value: PNG_ZOOM }, + font: { loadSystemFonts: false, fontFiles: writeFonts(fontDir), defaultFontFamily: "Inter" }, + }) + .render() + .asPng(); + writeFileSync(join(out, `${name}.png`), png); + } + console.log(`${loom} → ${formats.map((f) => join(out, `${name}.${f}`)).join(", ")}`); + } + } finally { + if (fontDir) rmSync(fontDir, { recursive: true, force: true }); + } + return 0; +} + +main(process.argv.slice(2)).then( + (code) => (process.exitCode = code), + (err: Error) => { + console.error(`ferrule: ${err.message}`); + process.exitCode = 1; + }, +); diff --git a/src/layout/elk.ts b/src/layout/elk.ts index 7c72b5d..6d83aa6 100644 --- a/src/layout/elk.ts +++ b/src/layout/elk.ts @@ -7,7 +7,7 @@ import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api.js"; // elkjs is CommonJS: at runtime the default import is the constructor, but its types describe // the module, whose `default` is the constructor. const ELK = elkModule as unknown as typeof elkModule.default; -import type { Sheet } from "../sheet.ts"; +import type { Card, Sheet } from "../sheet.ts"; import type { Placement, Route } from "./types.ts"; export const ELK_OPTIONS: Record = { @@ -17,12 +17,13 @@ export const ELK_OPTIONS: Record = { "elk.randomSeed": "1", "elk.padding": "[top=24,left=24,bottom=24,right=24]", "elk.spacing.nodeNode": "36", - "elk.spacing.edgeEdge": "14", + // Parallel wires need room to be told apart, especially black ones on the dark theme. + "elk.spacing.edgeEdge": "20", "elk.spacing.edgeNode": "20", "elk.spacing.edgeLabel": "4", "elk.layered.spacing.nodeNodeBetweenLayers": "64", "elk.layered.spacing.edgeNodeBetweenLayers": "24", - "elk.layered.spacing.edgeEdgeBetweenLayers": "14", + "elk.layered.spacing.edgeEdgeBetweenLayers": "20", "elk.edgeLabels.inline": "true", "elk.edgeLabels.placement": "CENTER", "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", @@ -31,25 +32,74 @@ export const ELK_OPTIONS: Record = { "elk.layered.thoroughness": "40", }; +/** The gap between the two halves of a mated pair. */ +const MATE_GAP = 48; + +interface Pair { + id: string; + a: Card; + b: Card; + mate: string; +} + +/** + * Mated connectors that can sit side by side as one node: the plug takes wires only from the + * west and the socket only to the east, so nothing has to pass between them. Laid out as one, a + * pair always sits side by side and its mate is drawn as the short link it is. + */ +function pairs(sheet: Sheet): Pair[] { + const cards = new Map(sheet.cards.map((c) => [c.id, c])); + const taken = new Set(); + const out: Pair[] = []; + for (const w of sheet.wires) { + if (w.kind !== "mate") continue; + const a = cards.get(w.from.card)!, b = cards.get(w.to.card)!; + if (taken.has(a.id) || taken.has(b.id)) continue; + if (a.ports.some((p) => p.side === "E" && p.id !== "mate:E")) continue; + if (b.ports.some((p) => p.side === "W" && p.id !== "mate:W")) continue; + taken.add(a.id).add(b.id); + out.push({ id: `pair:${a.id}+${b.id}`, a, b, mate: w.id }); + } + return out; +} + +// No wrapping into rows. ELK's own cut tends to fall through a multi-core cable, which sends a +// ribbon of wires round the sheet's edge, and that reads worse than a wide sheet. elkjs can't take +// a hand-picked cut either: it can't deserialise the list option. A wide sheet stays one row. export async function layoutElk(sheet: Sheet, options: Record = {}): Promise { + const paired = pairs(sheet); + const inPair = new Set(paired.flatMap((p) => [p.a.id, p.b.id])); + const port = (card: Card, p: Card["ports"][number], dx = 0) => ({ + id: `${card.id}/${p.id}`, + x: p.x + dx, + y: p.y, + width: 0, + height: 0, + layoutOptions: { "elk.port.side": p.side === "W" ? "WEST" : "EAST" }, + }); const graph: ElkNode = { id: "root", layoutOptions: { ...ELK_OPTIONS, ...options }, - children: sheet.cards.map((c) => ({ - id: c.id, - width: c.w, - height: c.h, - layoutOptions: { "elk.portConstraints": "FIXED_POS" }, - ports: c.ports.map((p) => ({ - id: `${c.id}/${p.id}`, - x: p.x, - y: p.y, - width: 0, - height: 0, - layoutOptions: { "elk.port.side": p.side === "W" ? "WEST" : "EAST" }, + children: [ + ...sheet.cards.filter((c) => !inPair.has(c.id)).map((c) => ({ + id: c.id, + width: c.w, + height: c.h, + layoutOptions: { "elk.portConstraints": "FIXED_POS" }, + ports: c.ports.map((p) => port(c, p)), })), - })), - edges: sheet.wires.map((w) => ({ + ...paired.map(({ id, a, b }) => ({ + id, + width: a.w + MATE_GAP + b.w, + height: Math.max(a.h, b.h), + layoutOptions: { "elk.portConstraints": "FIXED_POS" }, + ports: [ + ...a.ports.filter((p) => p.side === "W").map((p) => port(a, p)), + ...b.ports.filter((p) => p.side === "E").map((p) => port(b, p, a.w + MATE_GAP)), + ], + })), + ], + edges: sheet.wires.filter((w) => !paired.some((p) => p.mate === w.id)).map((w) => ({ id: w.id, sources: [`${w.from.card}/${w.from.port}`], targets: [`${w.to.card}/${w.to.port}`], @@ -60,6 +110,17 @@ export async function layoutElk(sheet: Sheet, options: Record = const out = await new ELK().layout(graph); const cards = new Map(out.children!.map((n) => [n.id, { x: n.x!, y: n.y! }])); const routes = new Map(); + for (const { id, a, b, mate } of paired) { + const at = cards.get(id)!; + cards.delete(id); + cards.set(a.id, at); + cards.set(b.id, { x: at.x + a.w + MATE_GAP, y: at.y }); + const ya = at.y + a.ports.find((p) => p.id === "mate:E")!.y; + const yb = at.y + b.ports.find((p) => p.id === "mate:W")!.y; + const x0 = at.x + a.w, x1 = x0 + MATE_GAP, mid = x0 + MATE_GAP / 2; + const points = ya === yb ? [{ x: x0, y: ya }, { x: x1, y: yb }] : [{ x: x0, y: ya }, { x: mid, y: ya }, { x: mid, y: yb }, { x: x1, y: yb }]; + routes.set(mate, { kind: "poly", points }); + } for (const e of out.edges as ElkExtendedEdge[]) { const s = e.sections![0]; const label = e.labels?.[0]; diff --git a/src/measure.ts b/src/measure.ts index b25f994..ae5e86d 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -1,34 +1,86 @@ -// Text measurement against the bundled Inter, so a card is the same width on every machine. +// The bundled Inter: measured with HarfBuzz, so a card is the same width on every machine, and +// subset into each SVG, so every viewer draws the text with the font it was measured in. -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import opentype from "opentype.js"; +import { join } from "node:path"; +import fontverter from "fontverter"; +import * as hb from "harfbuzzjs"; +import subsetFont from "subset-font"; const require = createRequire(import.meta.url); -const fonts = new Map(); - -function font(weight: number): opentype.Font { - let f = fonts.get(weight); - if (!f) { - const path = require.resolve(`@fontsource/inter/files/inter-latin-${weight}-normal.woff`); - const buf = readFileSync(path); - f = opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); - fonts.set(weight, f); + +/** + * The faces the Instrument style draws with, by their file in inter-ui. Fold rows are the one + * italic. These are the full faces, not a Latin subset, so Ω and → are there to draw. + */ +export const FACES = [ + { weight: 400, style: "normal", file: "Inter-Regular" }, + { weight: 500, style: "normal", file: "Inter-Medium" }, + { weight: 600, style: "normal", file: "Inter-SemiBold" }, + { weight: 400, style: "italic", file: "Inter-Italic" }, +] as const; + +type Face = (typeof FACES)[number]; +const key = (weight: number, style: string) => `${weight}-${style}`; + +interface Loaded { + woff2: Buffer; + ttf: Buffer; + font: hb.Font; + upem: number; +} + +// Converted once at load: HarfBuzz and resvg both need TrueType, and inter-ui ships WOFF2. +const loaded = new Map(); +for (const f of FACES) { + const woff2 = readFileSync(require.resolve(`inter-ui/web/${f.file}.woff2`)); + const ttf: Buffer = await fontverter.convert(woff2, "truetype"); + const face = new hb.Face(new hb.Blob(ttf)); + loaded.set(key(f.weight, f.style), { woff2, ttf, font: new hb.Font(face), upem: face.upem }); +} + +function get(weight: number, italic: boolean): Loaded { + const l = loaded.get(key(weight, italic ? "italic" : "normal")); + if (!l) throw new Error(`Inter ${weight}${italic ? " italic" : ""} is not bundled`); + return l; +} + +/** Advance width of `text` in px, shaped with kerning, rounded to a hundredth so output is stable. */ +export function textWidth(text: string, size: number, weight = 400, italic = false): number { + if (!text) return 0; + const { font, upem } = get(weight, italic); + const buf = new hb.Buffer(); + buf.addText(text); + buf.guessSegmentProperties(); + hb.shape(font, buf); + const units = buf.getGlyphInfosAndPositions().reduce((sum, g) => sum + (g.xAdvance ?? 0), 0); + return Math.round((units / upem) * size * 100) / 100; +} + +/** `@font-face` rules carrying each face subset to the characters it draws, as WOFF2 data URLs. */ +export async function fontFaces(used: Map): Promise { + const rules: string[] = []; + for (const f of FACES) { + const chars = used.get(f); + if (!chars) continue; + const data = await subsetFont(get(f.weight, f.style === "italic").woff2, chars, { targetFormat: "woff2" }); + rules.push( + `@font-face{font-family:Inter;font-weight:${f.weight};font-style:${f.style};` + + `src:url(data:font/woff2;base64,${data.toString("base64")}) format("woff2")}`, + ); } - return f; + return rules.join(""); } -// Glyph by glyph rather than getAdvanceWidth, because opentype.js cannot apply some of Inter's -// substitution lookups and throws. Inter kerns through GPOS, which getKerningValue does not read, -// so widths come out a touch wide. Cards have padding to spare; step 4 should shape with HarfBuzz. -/** Advance width of `text` in px, rounded to a hundredth so output is stable. */ -export function textWidth(text: string, size: number, weight = 400): number { - const f = font(weight); - const glyphs = [...text].map((ch) => f.charToGlyph(ch)); - let units = 0; - glyphs.forEach((g, i) => { - units += g.advanceWidth ?? 0; - if (i) units += f.getKerningValue(glyphs[i - 1], g); +/** Write the faces as TrueType files, for a rasteriser that only loads fonts from disk. */ +export function writeFonts(dir: string): string[] { + mkdirSync(dir, { recursive: true }); + return FACES.map((f) => { + const path = join(dir, `inter-${key(f.weight, f.style)}.ttf`); + writeFileSync(path, get(f.weight, f.style === "italic").ttf); + return path; }); - return Math.round((units / f.unitsPerEm) * size * 100) / 100; } + +export type { Face }; diff --git a/src/model.ts b/src/model.ts index 147515b..e292bdd 100644 --- a/src/model.ts +++ b/src/model.ts @@ -4,6 +4,8 @@ export interface Pin { num: string; label: string; + /** Colour codes for a pin's marking, one per stripe, as on a cable's wires. Empty when unmarked. */ + colours: string[]; } export interface Connector { @@ -14,6 +16,8 @@ export interface Connector { pins: Pin[]; /** A `style: simple` connector has no pin table: wires attach to the card itself. */ simple: boolean; + /** Pins bridged on the connector itself, as pairs of pin numbers. */ + loops: [string, string][]; accent: string; notes: string[]; } @@ -21,6 +25,8 @@ export interface Connector { export interface CableWire { index: number; label: string; + /** The colour code as written, such as "GNYE". A connection can name a wire by it. */ + code: string; /** Colour codes, one per stripe: ["GN", "YE"] for a green wire with a yellow tracer. */ colours: string[]; } @@ -55,4 +61,11 @@ export interface Harness { connectors: Connector[]; cables: Cable[]; links: Link[]; + /** Connectors mated as a whole, such as the two halves of an inline plug. */ + mates: Mate[]; +} + +export interface Mate { + from: string; + to: string; } diff --git a/src/render.ts b/src/render.ts index 0d5095a..958a22c 100644 --- a/src/render.ts +++ b/src/render.ts @@ -4,16 +4,23 @@ import { WIRE } from "./colours.ts"; import type { Placement, Pt, Route } from "./layout/types.ts"; +import { FACES, fontFaces, type Face } from "./measure.ts"; import { STYLE as S, tagWidth, type Card, type Sheet, type Wire } from "./sheet.ts"; +// A white core on a white card needs a darker casing than the rest to stay visible; a black one +// on the dark ground needs a lighter one. export const THEMES = { light: { bg: "#eef0f3", card: "#ffffff", line: "#d5d9df", text: "#1b1f24", muted: "#5e6670", faint: "#9aa1aa", - casing: "#7f8792", "case-bk": "#7f8792", "case-wh": "#7f8792", "tag-bg": "#ffffff", + casing: "#7f8792", "case-bk": "#7f8792", "case-wh": "#5e6670", "tag-bg": "#ffffff", + // A pure white core on the light ground reads as a hollow outline; a pale grey reads as filled. + "core-bk": WIRE.BK, "core-wh": "#dde0e5", }, dark: { bg: "#121418", card: "#1c1f25", line: "#323741", text: "#e8eaed", muted: "#9aa1ab", faint: "#626a75", casing: "#5d6570", "case-bk": "#9aa1ab", "case-wh": "#5d6570", "tag-bg": "#23272e", + // On the dark ground a true black core vanishes and leaves its casing looking hollow. + "core-bk": "#474d57", "core-wh": WIRE.WH, }, } as const; @@ -39,12 +46,29 @@ text{font-family:Inter,'Helvetica Neue',Arial,sans-serif;fill:var(--text)} .case-wh{stroke:var(--case-wh)} .core{fill:none;stroke-linejoin:round} .tag{fill:var(--tag-bg);stroke:var(--line);stroke-width:1} -.port{fill:var(--card);stroke:var(--muted);stroke-width:1.2}`; +.port{fill:var(--card);stroke:var(--muted);stroke-width:1.2} +.chip{stroke:var(--faint);stroke-width:1} +.mate{fill:none;stroke:var(--muted);stroke-linecap:round;stroke-linejoin:round} +.cap{stroke:var(--muted);stroke-width:1.5;stroke-linecap:round}`; const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const n = (v: number) => String(Math.round(v * 10) / 10); -export function render(sheet: Sheet, place: Placement, caption: string): string { +/** The characters each face draws on one sheet, so its embedded font carries only those glyphs. */ +type Glyphs = Map>; + +/** A `` element, recording its characters against the face it is set in. */ +function text(g: Glyphs, attrs: string, body: string, weight = 400, italic = false): string { + const face = FACES.find((f) => f.weight === weight && (f.style === "italic") === italic)!; + const set = g.get(face) ?? new Set(); + for (const ch of body) set.add(ch); + g.set(face, set); + const style = (weight === 400 ? "" : ` font-weight="${weight}"`) + (italic ? ' font-style="italic"' : ""); + return `${esc(body)}`; +} + +export async function render(sheet: Sheet, place: Placement, caption = ""): Promise { + const g: Glyphs = new Map(); const W = Math.ceil(place.width); const H = Math.ceil(place.height) + TITLE_BAND; const cards = new Map(sheet.cards.map((c) => [c.id, c])); @@ -55,90 +79,176 @@ export function render(sheet: Sheet, place: Placement, caption: string): string const shift = (p: Pt) => ({ x: p.x, y: p.y + TITLE_BAND }); const o: string[] = [ - ``, - ``, ``, - `${esc(sheet.title)}`, - `${esc(caption)}`, + text(g, 'x="24" y="36" font-size="20"', sheet.title, 600), ]; - for (const c of sheet.cards) o.push(...drawCard(c, at(c.id))); + if (caption) o.push(text(g, 'class="muted" x="24" y="56" font-size="11.5"', caption)); + for (const c of sheet.cards) o.push(...drawCard(g, c, at(c.id))); const tags: string[] = []; const ports: string[] = []; for (const w of sheet.wires) { const r = place.routes.get(w.id)!; const pts = r.points.map(shift); + if (w.kind === "mate") { + o.push(...drawMate(path(r.kind, pts))); + continue; + } o.push(...drawWire(path(r.kind, pts), w)); - if (w.tag && r.tag) tags.push(...drawTag(shift(r.tag), w.tag.text, w.tag.w)); + if (w.tag && r.tag) tags.push(...drawTag(g, nearest(pts, shift(r.tag)), w.tag.text, w.tag.w)); for (const [end, p] of [[w.from, pts[0]], [w.to, pts[pts.length - 1]]] as const) { if (cards.get(end.card)!.kind !== "cable") ports.push(``); } } + for (const c of sheet.cards) for (const loop of c.loops) o.push(...drawLoop(c, at(c.id), loop)); // Cores pass straight through a cable card, drawn over the card so the run reads as continuous. for (const c of sheet.cards.filter((k) => k.kind === "cable")) { const p = at(c.id); c.rows.forEach((row, i) => { const y = p.y + c.rowTop + i * c.rowH + c.rowH / 2; - const wire = sheet.wires.find((w) => w.to.card === c.id && w.to.port.startsWith(`${row.key}:`)) ?? - sheet.wires.find((w) => w.from.card === c.id && w.from.port.startsWith(`${row.key}:`)); + const into = sheet.wires.find((w) => w.to.card === c.id && w.to.port.startsWith(`${row.key}:`)); + const out = sheet.wires.find((w) => w.from.card === c.id && w.from.port.startsWith(`${row.key}:`)); + const wire = into ?? out; if (!wire) return; o.push(...drawWire(`M${n(p.x)},${n(y)} L${n(p.x + c.w)},${n(y)}`, wire)); + // A core that goes nowhere on one side ends in a cap, so it reads as unterminated, not cut. + for (const [open, x] of [[!into, p.x], [!out, p.x + c.w]] as const) { + if (open) o.push(``); + } const label = row.label || row.num; - tags.push(...drawTag({ x: p.x + c.w / 2, y }, label, tagWidth(label))); + tags.push(...drawTag(g, { x: p.x + c.w / 2, y }, label, tagWidth(label))); }); } o.push(...new Set(ports), ...tags, ""); - return o.join("\n"); + + const head = [ + ``, + ``, + ]; + return [...head, ...o].join("\n"); } -function drawCard(c: Card, p: Pt): string[] { +function drawCard(g: Glyphs, c: Card, p: Pt): string[] { const o: string[] = []; const dash = c.kind === "cable" ? ' stroke-dasharray="5 3"' : ""; o.push(``); - o.push(``); - o.push(`${esc(c.title)}`); - if (c.sub) o.push(`${esc(c.sub)}`); + o.push(``); + o.push(text(g, `x="${n(p.x + 28)}" y="${n(p.y + 21)}" font-size="${S.titleSize}"`, c.title, 600)); + c.subLines.forEach((line, j) => { + o.push(text(g, `class="muted" x="${n(p.x + 12)}" y="${n(p.y + 39 + j * S.subLh)}" font-size="${S.subSize}"`, line)); + }); + const swatch = c.rows.some((r) => r.colours) ? S.swatchW : 0; // A cable card's rows are drawn later, over the cores that pass through it. if (c.kind !== "cable") c.rows.forEach((r, i) => { const ry = p.y + c.rowTop + i * c.rowH; const base = ry + c.rowH / 2 + S.rowSize * 0.36; if (i) o.push(``); if (r.fold) { - o.push(`${esc(r.fold)}`); + o.push(text(g, `class="faint" x="${n(p.x + c.w / 2)}" y="${n(base)}" text-anchor="middle" font-size="${S.rowSize - 1}"`, r.fold, 400, true)); return; } - const used = c.ports.some((q) => q.id.startsWith(`${r.key}:`)); - o.push(`${esc(r.num)}`); - o.push(`${esc(r.label)}`); + const used = c.ports.some((q) => q.id.startsWith(`${r.key}:`)) || c.loops.some((l) => l.includes(r.key)); + o.push(text(g, `class="${used ? "muted" : "faint"}" x="${n(p.x + S.numW - 8)}" y="${n(base)}" text-anchor="end" font-size="${S.rowSize - 1}"`, r.num)); + if (r.colours) o.push(...drawSwatch(p.x + S.numW + 8, ry + c.rowH / 2, r.colours)); + o.push(text(g, `${used ? "" : 'class="faint" '}x="${n(p.x + S.numW + 8 + swatch)}" y="${n(base)}" font-size="${S.rowSize}"`, r.label)); }); if (c.notes.length) { const ny = p.y + c.rowTop + c.rows.length * c.rowH; o.push(``); c.notes.forEach((line, j) => { - o.push(`${esc(line)}`); + o.push(text(g, `class="muted" x="${n(p.x + 12)}" y="${n(ny + 5 + (j + 1) * S.noteLh)}" font-size="${S.noteSize}"`, line)); }); } return o; } +/** A pin's colour marking: a small chip, split down the middle for a two-colour mark. */ +function drawSwatch(x: number, cy: number, colours: string[]): string[] { + const size = 8; + const y = cy - size / 2; + const [a, b] = colours; + const kase = `casing${casing(a)}`; + const o = [``]; + if (b) o.push(``); + return o; +} + +/** Two connectors plugged together: a chain of dots, which no wire is drawn as. */ +function drawMate(d: string): string[] { + return [``]; +} + +/** A bridge between two pins of one connector, drawn as a short grey wire looping off its side. */ +function drawLoop(c: Card, p: Pt, [a, b]: [string, string]): string[] { + const rowY = (key: string) => p.y + c.rowTop + c.rows.findIndex((r) => r.key === key) * c.rowH + c.rowH / 2; + // Loop off whichever side has no wire on either pin, preferring the east. With wires on both + // sides, the loop is a bracket inside the card's left margin instead, clear of every wire. + const busy = (side: string) => c.ports.some((q) => q.side === side && (q.id === `${a}:${side}` || q.id === `${b}:${side}`)); + const inside = busy("E") && busy("W"); + const east = !busy("E"); + const x = inside ? p.x + 5 : east ? p.x + c.w : p.x; + const out = inside ? p.x + 2 : east ? x + 14 : x - 14; + if (inside) { + const d = `M${n(x)},${n(rowY(a))} L${n(out)},${n(rowY(a))} L${n(out)},${n(rowY(b))} L${n(x)},${n(rowY(b))}`; + return [``]; + } + const pts = [{ x, y: rowY(a) }, { x: out, y: rowY(a) }, { x: out, y: rowY(b) }, { x, y: rowY(b) }]; + const wire: Wire = { id: "loop", from: { card: c.id, port: a }, to: { card: c.id, port: b }, colours: ["GY"], weight: 2.5 }; + return [ + ...drawWire(path("poly", pts), wire), + ...pts.filter((_, i) => i === 0 || i === 3).map((q) => ``), + ]; +} + +/** The extra casing class for a black or white core, which each theme outlines in its own colour. */ +function casing(code: string): string { + return code === "BK" ? " case-bk" : code === "WH" ? " case-wh" : ""; +} + +/** A core's stroke. Black and white follow the theme, so neither matches the ground it's drawn on. */ +function coreColour(code: string): string { + if (code === "BK") return "var(--core-bk)"; + if (code === "WH") return "var(--core-wh)"; + return WIRE[code] ?? WIRE.GY; +} + function drawWire(d: string, w: Wire): string[] { const [c1, c2] = w.colours; - const kase = c1 === "BK" ? " case-bk" : c1 === "WH" ? " case-wh" : ""; + const kase = casing(c1); const o = [ ``, - ``, + ``, ]; - if (c2) o.push(``); + if (c2) o.push(``); return o; } -function drawTag(c: Pt, text: string, w: number): string[] { +function drawTag(g: Glyphs, c: Pt, label: string, w: number): string[] { return [ ``, - `${esc(text)}`, + text(g, `x="${n(c.x)}" y="${n(c.y + S.tagSize * 0.36)}" text-anchor="middle" font-size="${S.tagSize}"`, label, 500), ]; } +/** + * The point on a route closest to `p`. A layout engine puts a label beside its wire; a tag sits + * on the wire it describes, so it can't be read as belonging to the next one along. + */ +export function nearest(route: Pt[], p: Pt): Pt { + let best = route[0]; + let bestD = Infinity; + for (let i = 1; i < route.length; i++) { + const a = route[i - 1], b = route[i]; + const dx = b.x - a.x, dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + const t = len2 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)) : 0; + const q = { x: a.x + t * dx, y: a.y + t * dy }; + const d = (q.x - p.x) ** 2 + (q.y - p.y) ** 2; + if (d < bestD) [best, bestD] = [q, d]; + } + return best; +} + /** An SVG path for a route: filleted corners on a polyline, cubic segments on a spline. */ export function path(kind: Route["kind"], pts: Pt[]): string { if (kind === "bezier") { diff --git a/src/sheet.ts b/src/sheet.ts index ec6609f..bdedc91 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -18,15 +18,20 @@ export const STYLE = { rowSize: 11.5, noteSize: 10, noteLh: 14, + subLh: 13, tagSize: 10, tagH: 17, foldFrom: 3, + /** Room a pin's colour swatch takes before its label, on a card where any pin has one. */ + swatchW: 14, }; export interface Row { key: string; num: string; label: string; + /** Colour codes for the pin's marking, one per stripe. */ + colours?: string[]; /** Set on a row that stands in for a run of unused pins. */ fold?: string; } @@ -44,13 +49,16 @@ export interface Card { id: string; kind: "connector" | "simple" | "cable"; title: string; - sub: string; + /** Type and subtype, wrapped to the card. */ + subLines: string[]; accent: string; w: number; h: number; rowTop: number; rowH: number; rows: Row[]; + /** Pins bridged on the card itself, by row key. */ + loops: [string, string][]; notes: string[]; ports: Port[]; } @@ -63,6 +71,8 @@ export interface Tag { export interface Wire { id: string; + /** A mate is two connectors plugged together, drawn between their title bars. */ + kind?: "mate"; from: { card: string; port: string }; to: { card: string; port: string }; colours: string[]; @@ -93,13 +103,17 @@ export function buildSheet(h: Harness): Sheet { return { card, port: `${key}:${side}` }; }; const cables = new Map(h.cables.map((c) => [c.id, c])); + // A single core joining two pins is a run, drawn as one wire with a tag. One that splits, or + // ends open, needs somewhere for its branches to meet, so it gets a card like a multi-core. + const uses = new Map(); + for (const l of h.links) uses.set(l.cable, (uses.get(l.cable) ?? 0) + 1); + const isRun = (c: Cable) => c.wires.length === 1 && uses.get(c.id) === 1 && h.links.some((l) => l.cable === c.id && l.from && l.to); for (const [i, l] of h.links.entries()) { const c = cables.get(l.cable)!; const core = c.wires[l.wire - 1]; const base = { colours: core.colours.length ? core.colours : ["GY"], weight: weight(c.gauge) }; - if (c.wires.length === 1) { - if (!l.from || !l.to) throw new Error(`${c.template}: a single-core run needs both ends`); + if (isRun(c) && l.from && l.to) { wires.push({ id: `w${i}`, from: touch(l.from.connector, l.from.pin, "E"), @@ -114,9 +128,22 @@ export function buildSheet(h: Harness): Sheet { if (l.to) wires.push({ id: `w${i}b`, from: touch(c.id, key, "E"), to: touch(l.to.connector, l.to.pin, "W"), ...base }); } + for (const [i, m] of h.mates.entries()) { + wires.push({ id: `m${i}`, kind: "mate", from: touch(m.from, "mate", "E"), to: touch(m.to, "mate", "W"), colours: [], weight: 0 }); + } + + // Templates drawn as more than one autogenerated card, whose titles need a number. + const drawn = [...h.connectors, ...h.cables.filter((c) => !isRun(c))].filter((c) => c.id.startsWith("__")); + const repeated = new Set(drawn.map((c) => c.template).filter((t, i, all) => all.indexOf(t) !== all.lastIndexOf(t))); + const cards: Card[] = []; - for (const c of h.connectors) cards.push(connectorCard(c, used.get(c.id) ?? new Map())); - for (const c of h.cables) if (c.wires.length > 1) cards.push(cableCard(c, used.get(c.id) ?? new Map())); + for (const c of h.connectors) { + const pins = used.get(c.id) ?? new Map>(); + // A looped pin is in use even with no wire on it, so it isn't folded away. + const inUse = new Set([...pins.keys(), ...c.loops.flat()]); + cards.push(connectorCard(c, pins, inUse, repeated)); + } + for (const c of h.cables) if (!isRun(c)) cards.push(cableCard(c, used.get(c.id) ?? new Map(), repeated)); return { title: h.title, cards, wires }; } @@ -136,17 +163,30 @@ function cableSpec(c: Cable): string { return [c.gauge == null ? "" : `${fmt(c.gauge)} mm²`, c.length].filter(Boolean).join(" · "); } -function fmt(n: number): string { +export function fmt(n: number): string { return String(Number(n.toFixed(2))); } -function connectorCard(c: Connector, used: Map>): Card { +/** + * A card's title. An autogenerated instance (`__REAR_LIGHT_2`) shows its template and number, + * `REAR_LIGHT · 2`, so two of the same part can be told apart. A part drawn once is just its + * template. + */ +export function displayName(id: string, template: string, repeated: Set): string { + const m = /^__.+_(\d+)$/.exec(id); + if (!m) return id; + return repeated.has(template) ? `${template} · ${m[1]}` : template; +} + +function connectorCard(c: Connector, used: Map>, inUse: Set, repeated: Set): Card { const sub = [c.type, c.subtype].filter(Boolean).join(" · "); + const title = displayName(c.id, c.template, repeated); if (c.simple) { - const w = width([textWidth(c.id, STYLE.titleSize, 600) + 40, textWidth(sub, STYLE.subSize) + 24]); - const card = base(c.id, "simple", c.id, sub, c.accent, w, [], []); - card.h = STYLE.simpleH; - card.ports = ports(used, card, () => STYLE.simpleH / 2); + const w = width([textWidth(title, STYLE.titleSize, 600) + 40, textWidth(sub, STYLE.subSize) + 24]); + const card = base(c.id, "simple", title, sub, c.accent, w, [], []); + card.subLines = wrap([sub], w - 24, STYLE.subSize); + card.h = STYLE.simpleH + extraSubLines(card) * STYLE.subLh; + card.ports = ports(used, card, () => card.h / 2); return card; } const rows: Row[] = []; @@ -159,37 +199,52 @@ function connectorCard(c: Connector, used: Map>): Card { run = []; }; for (const p of c.pins) { - const row = { key: p.num, num: p.num, label: p.label || p.num }; - if (used.has(p.num)) { + const row: Row = { key: p.num, num: p.num, label: p.label || p.num }; + if (p.colours.length) row.colours = p.colours; + if (inUse.has(p.num)) { flush(); rows.push(row); } else run.push(row); } flush(); - return finish(base(c.id, "connector", c.id, sub, c.accent, 0, rows, c.notes), used); + const card = base(c.id, "connector", title, sub, c.accent, 0, rows, c.notes); + card.loops = c.loops; + return finish(card, used); } -function cableCard(c: Cable, used: Map>): Card { - const title = c.id.startsWith("__") ? c.template : c.id; +function cableCard(c: Cable, used: Map>, repeated: Set): Card { + const title = displayName(c.id, c.template, repeated); const sub = [c.type, cableSpec(c)].filter(Boolean).join(" · "); const rows = c.wires.map((w) => ({ key: String(w.index), num: String(w.index), label: w.label })); return finish(base(c.id, "cable", title, sub, c.accent, 0, rows, c.notes), used); } function base(id: string, kind: Card["kind"], title: string, sub: string, accent: string, w: number, rows: Row[], notes: string[]): Card { - return { id, kind, title, sub, accent, w, h: 0, rowTop: STYLE.headH, rowH: STYLE.rowH, rows, notes, ports: [] }; + return { id, kind, title, subLines: sub ? [sub] : [], accent, w, h: 0, rowTop: STYLE.headH, rowH: STYLE.rowH, rows, loops: [], notes, ports: [] }; +} + +/** Subtitle lines beyond the first, each of which deepens the header. */ +function extraSubLines(card: Card): number { + return Math.max(0, card.subLines.length - 1); } function finish(card: Card, used: Map>): Card { const s = STYLE; + const swatch = card.rows.some((r) => r.colours) ? s.swatchW : 0; + const notes = paragraphs(card.notes); + // Notes may widen a card up to its limit, so they wrap less and the card stays shorter. card.w = width([ textWidth(card.title, s.titleSize, 600) + 28 + s.pad, - textWidth(card.sub, s.subSize) + 2 * s.pad, - ...card.rows.map((r) => (r.fold ? textWidth(r.fold, s.rowSize - 1) + 2 * s.pad : s.numW + 8 + textWidth(r.label, s.rowSize) + 34)), + ...card.subLines.map((l) => textWidth(l, s.subSize) + 2 * s.pad), + ...card.rows.map((r) => (r.fold ? textWidth(r.fold, s.rowSize - 1, 400, true) + 2 * s.pad : s.numW + 8 + swatch + textWidth(r.label, s.rowSize) + 34)), + ...notes.map((n) => textWidth(n, s.noteSize) + 2 * s.pad), ]); - card.notes = wrap(card.notes, card.w - 2 * s.pad); - card.h = s.headH + card.rows.length * s.rowH + (card.notes.length ? 10 + card.notes.length * s.noteLh : 0); + card.subLines = wrap(card.subLines, card.w - 2 * s.pad, s.subSize); + card.notes = wrap(notes, card.w - 2 * s.pad, s.noteSize); + card.rowTop = s.headH + extraSubLines(card) * s.subLh; + card.h = card.rowTop + card.rows.length * s.rowH + (card.notes.length ? 10 + card.notes.length * s.noteLh : 0); card.ports = ports(used, card, (key) => { + if (key === "mate") return card.rowTop / 2; const i = card.rows.findIndex((r) => r.key === key); return card.rowTop + i * card.rowH + card.rowH / 2; }); @@ -197,7 +252,9 @@ function finish(card: Card, used: Map>): Card { } function ports(used: Map>, card: Card, y: (key: string) => number): Port[] { - const keys = [...used.keys()].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)); + // The mate port sits in the title bar, above every pin, so it sorts first. + const rank = (k: string) => (k === "mate" ? -1 : Number(k)); + const keys = [...used.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); return keys.flatMap((key) => (["W", "E"] as const).filter((s) => used.get(key)!.has(s)).map((side) => ({ id: `${key}:${side}`, side, x: side === "W" ? 0 : card.w, y: y(key) })), ); @@ -208,13 +265,27 @@ function width(candidates: number[]): number { return Math.min(STYLE.maxW, Math.ceil(w / 2) * 2); } -function wrap(notes: string[], max: number): string[] { +/** + * Source lines rejoined where the author wrapped a sentence by hand, which wrapping again line by + * line would leave as orphans. Only a line starting in lowercase continues the one before: a + * capital, a bullet or a number starts a new item, and those line breaks are deliberate. + */ +function paragraphs(lines: string[]): string[] { + const out: string[] = []; + for (const line of lines) { + if (out.length && /^\p{Ll}/u.test(line)) out[out.length - 1] += ` ${line}`; + else out.push(line); + } + return out; +} + +function wrap(texts: string[], max: number, size: number): string[] { const out: string[] = []; - for (const note of notes) { + for (const text of texts) { let line = ""; - for (const word of note.split(/\s+/)) { + for (const word of text.split(/\s+/)) { const next = line ? `${line} ${word}` : word; - if (line && textWidth(next, STYLE.noteSize) > max) { + if (line && textWidth(next, size) > max) { out.push(line); line = word; } else line = next; diff --git a/src/tables.ts b/src/tables.ts new file mode 100644 index 0000000..a4e794a --- /dev/null +++ b/src/tables.ts @@ -0,0 +1,105 @@ +// The harness as Markdown: a cut list and a pinout for every connector. GitHub renders the tables +// on a phone, where a wide diagram is hard to follow pin by pin. + +import type { Cable, Connector, End, Harness } from "./model.ts"; +import { displayName, fmt } from "./sheet.ts"; + +const NONE = "–"; + +/** One conductor with every end it lands on, gathered from all the connection sets that name it. */ +interface Core { + cable: Cable; + wire: number; + from: End[]; + to: End[]; +} + +export function tables(h: Harness): string { + const connectors = new Map(h.connectors.map((c) => [c.id, c])); + const cables = new Map(h.cables.map((c) => [c.id, c])); + + // Fresh instances of a template used more than once are numbered. Unlike the diagram, this + // counts single-core runs too: they have no card there, but each is a row here to cut. + const fresh = [...h.connectors, ...h.cables].filter((c) => c.id.startsWith("__")); + const repeated = new Set(fresh.map((c) => c.template).filter((t, i, all) => all.indexOf(t) !== all.lastIndexOf(t))); + const name = (c: Connector | Cable) => displayName(c.id, c.template, repeated); + const end = (e: End) => { + const c = connectors.get(e.connector)!; + if (c.simple) return name(c); + const label = c.pins.find((p) => p.num === e.pin)?.label; + return `${name(c)} ${e.pin}${label ? ` · ${label}` : ""}`; + }; + const ends = (list: End[]) => (list.length ? list.map(end).join(", ") : NONE); + + const cores = new Map(); + for (const l of h.links) { + const key = `${l.cable}#${l.wire}`; + if (!cores.has(key)) cores.set(key, { cable: cables.get(l.cable)!, wire: l.wire, from: [], to: [] }); + const core = cores.get(key)!; + if (l.from && !core.from.some((e) => same(e, l.from!))) core.from.push(l.from); + if (l.to && !core.to.some((e) => same(e, l.to!))) core.to.push(l.to); + } + const order = [...cores.values()].sort( + (a, b) => h.cables.indexOf(a.cable) - h.cables.indexOf(b.cable) || a.wire - b.wire, + ); + const colour = (core: Core) => core.cable.wires[core.wire - 1].code || NONE; + + const out = [`# ${h.title}`, ""]; + + out.push("## Cut list", ""); + out.push(row(["Cable", "Core", "Colour", "Size", "Length", "From", "To"]), row(Array(7).fill("---"))); + for (const core of order) { + const c = core.cable; + const label = c.wires[core.wire - 1].label; + out.push(row([ + name(c), + label ? `${core.wire} ${label}` : String(core.wire), + colour(core), + c.gauge == null ? NONE : `${fmt(c.gauge)} mm²`, + c.length || NONE, + ends(core.from), + ends(core.to), + ])); + } + + out.push("", "## Pinouts"); + for (const c of h.connectors) { + const mates = h.mates.flatMap((m) => (m.from === c.id ? [m.to] : m.to === c.id ? [m.from] : [])); + const about = [ + [c.type, c.subtype].filter(Boolean).join(", "), + ...mates.map((m) => `mates with ${name(connectors.get(m)!)}`), + ].filter(Boolean); + out.push("", `### ${name(c)}`, ""); + if (about.length) out.push(about.join(" · "), ""); + out.push(row(["Pin", "Label", "Wire", "Goes to"]), row(Array(4).fill("---"))); + for (const p of c.pins) { + const here = { connector: c.id, pin: p.num }; + const lines: [string, string][] = []; + for (const core of order) { + // A single core is named by its cable alone; a number after a cable is always a core. + const label = core.cable.wires.length === 1 + ? `${name(core.cable)} (${colour(core)})` + : `${name(core.cable)} core ${core.wire} (${colour(core)})`; + if (core.from.some((e) => same(e, here))) lines.push([label, ends(core.to)]); + if (core.to.some((e) => same(e, here))) lines.push([label, ends(core.from)]); + } + for (const [a, b] of c.loops) { + if (a === p.num) lines.push([`looped to ${b}`, ""]); + if (b === p.num) lines.push([`looped to ${a}`, ""]); + } + if (!lines.length) lines.push([NONE, ""]); + lines.forEach(([wire, to], i) => { + out.push(row(i ? ["", "", wire, to] : [c.simple ? NONE : p.num, p.label, wire, to])); + }); + } + } + return out.join("\n") + "\n"; +} + +function same(a: End, b: End): boolean { + return a.connector === b.connector && a.pin === b.pin; +} + +function row(cells: string[]): string { + return `|${cells.map((c) => (c ? ` ${c.replaceAll("|", "\\|")} ` : " ")).join("|")}|`; +} diff --git a/src/types/vendor.d.ts b/src/types/vendor.d.ts new file mode 100644 index 0000000..99a3d64 --- /dev/null +++ b/src/types/vendor.d.ts @@ -0,0 +1,12 @@ +// Neither package ships types. Only the calls ferrule makes are declared. + +declare module "fontverter" { + const fontverter: { + convert(font: Buffer, to: "truetype" | "woff" | "woff2" | "sfnt"): Promise; + }; + export default fontverter; +} + +declare module "subset-font" { + export default function subsetFont(font: Buffer, text: string, options?: { targetFormat?: "sfnt" | "truetype" | "woff" | "woff2" }): Promise; +} diff --git a/src/wireviz.ts b/src/wireviz.ts index 6de96d2..60c363b 100644 --- a/src/wireviz.ts +++ b/src/wireviz.ts @@ -1,23 +1,20 @@ // Reads a subset of WireViz YAML into the harness model. // // Supported: prepended files (the text is concatenated, so anchors cross files), `<<` merge keys, -// `pinlabels`/`pincount`, `style: simple`, `X.Y` named instances, `X.` fresh instances, and -// connection sets that alternate connector, cable, connector. +// `pinlabels`/`pincount`/`pincolors`, `loops`, `style: simple`, `X.Y` named instances, `X.` fresh +// instances, pin ranges such as `1-4`, and connection sets that alternate connector and cable, +// may start or end with a cable, and may mate two connectors with an `==` arrow. Where the fork +// and this reader could disagree, this follows the fork: see wireviz.py's parse() and connect(). import { parse } from "yaml"; import { hex, stripes } from "./colours.ts"; -import type { Cable, Connector, End, Harness, Link } from "./model.ts"; +import type { Cable, Connector, End, Harness, Link, Mate } from "./model.ts"; type Yaml = Record; -interface Ref { - kind: "connector" | "cable"; - id: string; - refs: (string | number)[] | null; -} - export function readWireviz(sources: string[], title: string): Harness { - const doc: Yaml = parse(sources.join("\n"), { merge: true, maxAliasCount: -1 }) ?? {}; + // uniqueKeys off: the fork loads with PyYAML, which keeps the last of a duplicated key. + const doc: Yaml = parse(sources.join("\n"), { merge: true, maxAliasCount: -1, uniqueKeys: false }) ?? {}; const connectorDefs: Yaml = doc.connectors ?? {}; const cableDefs: Yaml = doc.cables ?? {}; @@ -26,7 +23,7 @@ export function readWireviz(sources: string[], title: string): Harness { const fresh = new Map(); const links: Link[] = []; - function instance(designator: string): Omit { + function instance(designator: string): { kind: "connector" | "cable"; id: string } { const dot = designator.indexOf("."); const template = dot < 0 ? designator : designator.slice(0, dot); let id = dot < 0 ? designator : designator.slice(dot + 1); @@ -46,51 +43,117 @@ export function readWireviz(sources: string[], title: string): Harness { throw new Error(`${designator}: no connector or cable is defined as ${template}`); } + const mates: Mate[] = []; + + // Follows the fork's parse(): every entry becomes one column of `count` items, a string is + // repeated down its column and connects pin 1, and the columns are then read row by row. for (const set of doc.connections ?? []) { - const items: Ref[] = (set as unknown[]).map((item) => { - if (typeof item === "string") return { ...instance(item), refs: null }; - const [designator, refs] = Object.entries(item as Yaml)[0]; - return { ...instance(designator), refs: Array.isArray(refs) ? refs : [refs] }; + const entries = set as unknown[]; + const lengths = entries.flatMap((e) => + Array.isArray(e) ? [e.length] : e && typeof e === "object" ? [expand(Object.values(e)[0]).length] : [], + ); + const count = lengths[0] ?? 1; + if (lengths.some((n) => n !== count)) { + throw new Error(`${names(entries)}: every item in a connection set must reference the same number of connections`); + } + const columns: Item[][] = entries.map((e) => { + if (typeof e === "string") return Array.from({ length: count }, () => item(e, 1)); + if (Array.isArray(e)) return e.map((d) => item(String(d), 1)); + // One instance for the whole entry: `TWIN.: [1, 2]` is both cores of one new cable. + const [designator, refs] = Object.entries(e as Yaml)[0]; + const one = item(designator, 0); + return expand(refs).map((ref) => ({ ...one, ref })); }); - for (let i = 1; i < items.length; i++) { - if (items[i].kind === items[i - 1].kind) { - throw new Error(`${items[i - 1].id} and ${items[i].id}: a connection must alternate connector and cable`); + columns.forEach((col, i) => { + if (i && (col[0].kind === "connector") === (columns[i - 1][0].kind === "connector")) { + throw new Error(`${names(entries)}: a connection set must alternate connector and cable`); } - } - const count = Math.max(...items.map((it) => it.refs?.length ?? 1)); + }); + for (let k = 0; k < count; k++) { - items.forEach((it, i) => { - if (it.kind !== "cable") return; - const c = cables.get(it.id)!; - const end = (j: number): End | null => { - const other = items[j]; - if (!other) return null; - const conn = connectors.get(other.id)!; - return { connector: conn.id, pin: pinNum(conn, pick(other, k)) }; - }; - links.push({ cable: c.id, wire: wireIndex(c, pick(it, k)), from: end(i - 1), to: end(i + 1) }); + const row = columns.map((col) => col[k]); + const end = (it: Item | undefined): End | null => { + if (!it) return null; + const conn = connectors.get(it.id)!; + return { connector: conn.id, pin: pinNum(conn, it.ref) }; + }; + row.forEach((it, i) => { + if (it.kind === "cable") { + const c = cables.get(it.id)!; + links.push({ cable: c.id, wire: wireIndex(c, it.ref), from: end(row[i - 1]), to: end(row[i + 1]) }); + } else if (it.kind === "arrow") { + const from = row[i - 1], to = row[i + 1]; + if (!from || !to) throw new Error(`${names(entries)}: an arrow needs a connector on each side`); + // Only a whole-component mate is supported; the fork draws it once, from the first row. + if (!it.id.includes("=")) throw new Error(`${names(entries)}: pin-by-pin mates (${it.id}) are not supported`); + if (k === 0) mates.push({ from: from.id, to: to.id }); + } }); } } - return { title, connectors: [...connectors.values()], cables: [...cables.values()], links }; + function item(designator: string, ref: string | number): Item { + if (ARROW.test(designator)) return { kind: "arrow", id: designator.trim(), ref }; + return { ...instance(designator), ref }; + } + + return { title, connectors: [...connectors.values()], cables: [...cables.values()], links, mates }; } -function pick(ref: Ref, k: number): string | number | null { - if (!ref.refs) return null; - return ref.refs.length === 1 ? ref.refs[0] : ref.refs[k]; +/** One cell of a connection set: a component, or an arrow, and what it references in this row. */ +interface Item { + kind: "connector" | "cable" | "arrow"; + id: string; + ref: string | number; +} + +/** WireViz arrows: a run of `-` or of `=`, optionally headed with `<` and `>`. */ +const ARROW = /^\s*?\s*$/; + +/** WireViz's expand: a scalar or a list, where `a-b` between two integers is an inclusive range. */ +export function expand(value: unknown): (string | number)[] { + const out: (string | number)[] = []; + for (const raw of Array.isArray(value) ? value : [value]) { + const e = String(raw); + const range = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(e); + if (range) { + const a = Number(range[1]), b = Number(range[2]); + const step = a <= b ? 1 : -1; + for (let x = a; x !== b + step; x += step) out.push(x); + } else out.push(/^-?\d+$/.test(e.trim()) ? Number(e) : e); + } + return out; +} + +function names(entries: unknown[]): string { + return entries.map((e) => (typeof e === "string" ? e : Array.isArray(e) ? `[${e.join(", ")}]` : Object.keys(e as Yaml)[0])).join(" → "); } function connector(id: string, template: string, def: Yaml): Connector { const simple = def.style === "simple"; const labels: string[] = (def.pinlabels ?? []).map(String); - const count = simple ? 1 : Math.max(labels.length, Number(def.pincount ?? 0)); + const marks: string[] = (def.pincolors ?? []).map((c: unknown) => (c == null ? "" : String(c))); + const count = simple ? 1 : Number(def.pincount ?? 0) || Math.max(labels.length, marks.length); + if (labels.length > count) throw new Error(`${id}: ${labels.length} pin labels for ${count} pin(s)`); return { id, template, type: String(def.type ?? ""), subtype: String(def.subtype ?? ""), - pins: Array.from({ length: count }, (_, i) => ({ num: String(i + 1), label: labels[i] ?? "" })), + pins: Array.from({ length: count }, (_, i) => ({ + num: String(i + 1), + label: labels[i] ?? "", + colours: marks[i] ? stripes(marks[i]) : [], + })), + // The fork takes loop pins by number only, and rejects one the connector doesn't have. + loops: (def.loops ?? []).map((pair: unknown[]) => { + if (pair.length !== 2) throw new Error(`${id}: a loop joins exactly two pins`); + const ends = pair.map(String) as [string, string]; + for (const end of ends) { + if (!(Number(end) >= 1 && Number(end) <= count)) throw new Error(`${id}: no loop pin ${end}`); + } + return ends; + }), simple, accent: hex(def.bgcolor_title ?? def.bgcolor), notes: lines(def.notes), @@ -110,6 +173,7 @@ function cable(id: string, template: string, def: Yaml): Cable { wires: Array.from({ length: count }, (_, i) => ({ index: i + 1, label: labels[i] ?? "", + code: colours[i] ?? "", colours: colours[i] ? stripes(colours[i]) : [], })), accent: hex(def.bgcolor_title ?? def.bgcolor), @@ -117,19 +181,23 @@ function cable(id: string, template: string, def: Yaml): Cable { }; } -function pinNum(c: Connector, ref: string | number | null): string { +function pinNum(c: Connector, ref: string | number): string { if (c.simple) return "1"; - const byLabel = c.pins.find((p) => p.label !== "" && p.label === String(ref)); - if (byLabel) return byLabel.num; + const byLabel = c.pins.filter((p) => p.label !== "" && p.label === String(ref)); + if (byLabel.length > 1) throw new Error(`${c.id}: pin ${ref} is labelled more than once`); + if (byLabel.length) return byLabel[0].num; const byNum = c.pins.find((p) => p.num === String(ref)); if (byNum) return byNum.num; throw new Error(`${c.id}: no pin ${ref}`); } -function wireIndex(c: Cable, ref: string | number | null): number { - if (ref == null && c.wires.length === 1) return 1; - const byLabel = c.wires.find((w) => w.label !== "" && w.label === String(ref)); - if (byLabel) return byLabel.index; +/** A wire by colour code, then label, then number: the order the fork's connect() tries them. */ +function wireIndex(c: Cable, ref: string | number): number { + for (const key of ["code", "label"] as const) { + const hits = c.wires.filter((w) => w[key] !== "" && w[key] === String(ref)); + if (hits.length > 1) throw new Error(`${c.id}: ${ref} names more than one wire`); + if (hits.length) return hits[0].index; + } const n = Number(ref); if (Number.isInteger(n) && n >= 1 && n <= c.wires.length) return n; throw new Error(`${c.id}: no wire ${ref}`); diff --git a/test/metrics.test.ts b/test/metrics.test.ts index f4ae734..e04b046 100644 --- a/test/metrics.test.ts +++ b/test/metrics.test.ts @@ -5,7 +5,7 @@ import type { Placement, Route } from "../src/layout/types.ts"; import type { Card, Sheet } from "../src/sheet.ts"; const card = (id: string, w: number, h: number): Card => ({ - id, kind: "connector", title: id, sub: "", accent: "#888888", w, h, rowTop: 0, rowH: 24, rows: [], notes: [], ports: [], + id, kind: "connector", title: id, subLines: [], accent: "#888888", w, h, rowTop: 0, rowH: 24, rows: [], loops: [], notes: [], ports: [], }); const wire = (id: string, tag = false) => ({ id, from: { card: "A", port: "1:E" }, to: { card: "B", port: "1:W" }, colours: ["RD"], weight: 3, diff --git a/test/render.test.ts b/test/render.test.ts new file mode 100644 index 0000000..b792532 --- /dev/null +++ b/test/render.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { layoutElk } from "../src/layout/elk.ts"; +import { flatten, nearest, render } from "../src/render.ts"; +import { buildSheet } from "../src/sheet.ts"; +import { readWireviz } from "../src/wireviz.ts"; + +const loom = ` +connectors: + A: + type: "Sensor · 240–33 Ω" + pinlabels: ["V+", "GND"] + B: + pinlabels: ["IN", "GND"] +cables: + TWIN: + wirecount: 2 + colors: ["RD", "BK"] + wirelabels: ["V+", "GND"] +connections: + - + - A: [V+, GND] + - TWIN: [V+, GND] + - B: [IN, GND] +`; + +const svg = async () => { + const sheet = buildSheet(readWireviz([loom], "Sheet")); + return render(sheet, await layoutElk(sheet)); +}; + +test("a tag moves onto the nearest point of its wire", () => { + const route = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }]; + assert.deepEqual(nearest(route, { x: 40, y: 9 }), { x: 40, y: 0 }); + assert.deepEqual(nearest(route, { x: 112, y: 30 }), { x: 100, y: 30 }); +}); + +test("the font travels inside the SVG, subset to what it draws", async () => { + const s = await svg(); + assert.doesNotMatch(s, /@import|googleapis/); + assert.match(s, /@font-face\{font-family:Inter;font-weight:600;font-style:normal;src:url\(data:font\/woff2;base64,/); +}); + +test("the same sheet renders to the same bytes", async () => { + assert.equal(await svg(), await svg()); +}); + +test("flattening leaves no CSS variable for a rasteriser to miss", async () => { + for (const theme of ["light", "dark"] as const) { + const flat = flatten(await svg(), theme); + assert.doesNotMatch(flat, /var\(--/); + assert.doesNotMatch(flat, /prefers-color-scheme/); + } +}); + +test("a mated pair is laid out side by side, joined by a short mate", async () => { + const src = `connectors:\n P:\n pinlabels: ["A", "B"]\n S:\n pinlabels: ["A", "B"]\n X:\n pinlabels: ["A", "B"]\n Y:\n pinlabels: ["A", "B"]\ncables:\n C:\n wirecount: 2\nconnections:\n -\n - X: [A, B]\n - C.: [1, 2]\n - P: [A, B]\n -\n - P\n - [==]\n - S\n -\n - S: [A, B]\n - C.: [1, 2]\n - Y: [A, B]\n`; + const sheet = buildSheet(readWireviz([src], "x")); + const place = await layoutElk(sheet); + const p = place.cards.get("P")!, s = place.cards.get("S")!; + const pw = sheet.cards.find((c) => c.id === "P")!.w; + assert.equal(p.y, s.y); + assert.ok(s.x > p.x + pw && s.x - (p.x + pw) < 80); +}); diff --git a/test/sheet.test.ts b/test/sheet.test.ts index 1b7eb7c..fda21db 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { buildSheet, weight } from "../src/sheet.ts"; import type { Harness } from "../src/model.ts"; -const pins = (n: number) => Array.from({ length: n }, (_, i) => ({ num: String(i + 1), label: `P${i + 1}` })); +const pins = (n: number) => Array.from({ length: n }, (_, i) => ({ num: String(i + 1), label: `P${i + 1}`, colours: [] })); const conn = (id: string, n: number, simple = false) => ({ - id, template: id, type: "T", subtype: "", pins: simple ? pins(1) : pins(n), simple, accent: "#888888", notes: [], + id, template: id, type: "T", subtype: "", pins: simple ? pins(1) : pins(n), simple, loops: [], accent: "#888888", notes: [], }); const harness: Harness = { @@ -13,15 +13,16 @@ const harness: Harness = { connectors: [conn("A", 12), conn("B", 2), conn("ISO", 1, true)], cables: [ { id: "__RUN_1", template: "RUN", type: "", gauge: 50, length: "1.5 m", accent: "#888888", notes: [], - wires: [{ index: 1, label: "24V", colours: ["RD"] }] }, + wires: [{ index: 1, label: "24V", code: "RD", colours: ["RD"] }] }, { id: "TWIN", template: "TWIN", type: "Twin", gauge: 16, length: "0.75 m", accent: "#888888", notes: [], - wires: [{ index: 1, label: "24V", colours: ["RD"] }, { index: 2, label: "GND", colours: ["BK"] }] }, + wires: [{ index: 1, label: "24V", code: "RD", colours: ["RD"] }, { index: 2, label: "GND", code: "BK", colours: ["BK"] }] }, ], links: [ { cable: "__RUN_1", wire: 1, from: { connector: "A", pin: "1" }, to: { connector: "ISO", pin: "1" } }, { cable: "TWIN", wire: 1, from: { connector: "A", pin: "2" }, to: { connector: "B", pin: "1" } }, { cable: "TWIN", wire: 2, from: { connector: "A", pin: "7" }, to: { connector: "B", pin: "2" } }, ], + mates: [], }; const sheet = buildSheet(harness); @@ -79,3 +80,84 @@ test("a cable card carries its notes", () => { const h: Harness = { ...harness, cables: harness.cables.map((c) => (c.id === "TWIN" ? { ...c, notes: ["Verify gauge"] } : c)) }; assert.deepEqual(buildSheet(h).cards.find((c) => c.id === "TWIN")!.notes, ["Verify gauge"]); }); + +const more: Harness = { + ...harness, + connectors: [ + conn("A", 12), + conn("P", 4), + { ...conn("Q", 4), pins: pins(4).map((p, i) => ({ ...p, colours: i === 1 ? ["GN", "YE"] : [] })), loops: [["2", "3"]] }, + conn("R", 2), + ], + cables: [{ ...harness.cables[0], id: "FEED", template: "FEED" }], + links: [ + { cable: "FEED", wire: 1, from: { connector: "A", pin: "1" }, to: null }, + { cable: "FEED", wire: 1, from: null, to: { connector: "R", pin: "1" } }, + { cable: "FEED", wire: 1, from: null, to: { connector: "R", pin: "2" } }, + ], + mates: [{ from: "P", to: "Q" }], +}; +const sheet2 = buildSheet(more); +const card2 = (id: string) => sheet2.cards.find((c) => c.id === id)!; + +test("a single core that splits or ends open is a card, not a tag", () => { + assert.equal(card2("FEED").kind, "cable"); + assert.deepEqual(sheet2.wires.filter((w) => w.from.card === "FEED").map((w) => w.to), [ + { card: "R", port: "1:W" }, + { card: "R", port: "2:W" }, + ]); +}); + +test("a mate joins two connectors at their title bars", () => { + const m = sheet2.wires.find((w) => w.kind === "mate")!; + assert.deepEqual([m.from, m.to], [{ card: "P", port: "mate:E" }, { card: "Q", port: "mate:W" }]); + const port = card2("P").ports.find((p) => p.id === "mate:E")!; + assert.equal(port.y, card2("P").rowTop / 2); +}); + +test("looped pins stay unfolded and the loop is kept on the card", () => { + assert.deepEqual(card2("Q").rows.map((r) => r.fold ?? r.label), ["P1", "P2", "P3", "P4"]); + assert.deepEqual(card2("Q").loops, [["2", "3"]]); +}); + +test("pin colours reach the rows", () => { + assert.deepEqual(card2("Q").rows.map((r) => r.colours), [undefined, ["GN", "YE"], undefined, undefined]); +}); + +test("a repeated autogenerated part is titled by its template and number", () => { + const rename: Record = { B: "__LAMP_2", ISO: "__LAMP_1" }; + const id = (x: string) => rename[x] ?? x; + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (rename[c.id] ? { ...c, id: id(c.id), template: "LAMP" } : c)), + links: harness.links.map((l) => ({ ...l, to: l.to && { ...l.to, connector: id(l.to.connector) } })) }; + assert.equal(buildSheet(h).cards.find((c) => c.id === "__LAMP_2")!.title, "LAMP · 2"); +}); + +test("a long subtitle wraps inside the card and pushes the rows down", () => { + const sub = "Gigabit Ethernet Switch with eight ports and passive PoE passthrough on every port"; + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, type: sub } : c)) }; + const b = buildSheet(h).cards.find((c) => c.id === "B")!; + assert.ok(b.subLines.length > 1); + assert.equal(b.subLines.join(" "), sub); + assert.ok(b.rowTop > card("B").rowTop); +}); + +test("notes keep their bullets as paragraphs and rejoin wrapped source lines", () => { + const notes = ["- First point that the author", "wrapped by hand", "- Second point"]; + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, notes } : c)) }; + const b = buildSheet(h).cards.find((c) => c.id === "B")!; + assert.deepEqual(b.notes, ["- First point that the author wrapped by hand", "- Second point"]); +}); + +test("notes join only a lowercase continuation, so separate items stay separate", () => { + const notes = ["Cable is self-terminated", "Pin 8: CAN-L", "via BMS LOAD-", "LOAD- terminal: shunt"]; + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, notes } : c)) }; + assert.deepEqual(buildSheet(h).cards.find((c) => c.id === "B")!.notes, ["Cable is self-terminated", "Pin 8: CAN-L via BMS LOAD-", "LOAD- terminal: shunt"]); +}); + +test("a part used once isn't numbered", () => { + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, id: "__LAMP_1", template: "LAMP" } : c)), + links: harness.links.map((l) => (l.to?.connector === "B" ? { ...l, to: { ...l.to, connector: "__LAMP_1" } } : l)) }; + const s = buildSheet(h); + assert.equal(s.cards.find((c) => c.id === "__LAMP_1")!.title, "LAMP"); + assert.equal(s.cards.find((c) => c.id === "__RUN_1"), undefined); +}); diff --git a/test/tables.test.ts b/test/tables.test.ts new file mode 100644 index 0000000..671c5e5 --- /dev/null +++ b/test/tables.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { tables } from "../src/tables.ts"; +import type { Harness } from "../src/model.ts"; + +const pin = (num: string, label = "") => ({ num, label, colours: [] }); +const conn = (id: string, pins: ReturnType[], extra = {}) => ({ + id, template: id, type: "Deutsch DT", subtype: "", pins, simple: false, loops: [], accent: "#888888", notes: [], ...extra, +}); + +const harness: Harness = { + title: "test_loom", + connectors: [ + conn("PANEL", [pin("1", "PUMP"), pin("2", "GND")]), + conn("PLUG", [pin("1", "PUMP"), pin("2", "GND"), pin("3", "SPARE")], { loops: [["2", "3"]] }), + { ...conn("__WAGO_1", [pin("1")], { simple: true }), template: "WAGO" }, + conn("SOCKET", [pin("1")]), + ], + cables: [ + { id: "LOOM", template: "LOOM", type: "Multi-core", gauge: 1.5, length: "2 m", accent: "#888888", notes: [], + wires: [{ index: 1, label: "PUMP", code: "RD", colours: ["RD"] }, { index: 2, label: "GND", code: "GNYE", colours: ["GN", "YE"] }] }, + { id: "__RUN_1", template: "RUN", type: "", gauge: null, length: "", accent: "#888888", notes: [], + wires: [{ index: 1, label: "", code: "BK", colours: ["BK"] }] }, + { id: "__RUN_2", template: "RUN", type: "", gauge: null, length: "", accent: "#888888", notes: [], + wires: [{ index: 1, label: "", code: "BK", colours: ["BK"] }] }, + ], + links: [ + // One core, described by two connection sets: the far end is joined up across them. + { cable: "LOOM", wire: 1, from: null, to: { connector: "PLUG", pin: "1" } }, + { cable: "LOOM", wire: 1, from: { connector: "PANEL", pin: "1" }, to: null }, + { cable: "LOOM", wire: 2, from: { connector: "PANEL", pin: "2" }, to: { connector: "__WAGO_1", pin: "1" } }, + { cable: "__RUN_1", wire: 1, from: { connector: "__WAGO_1", pin: "1" }, to: { connector: "PLUG", pin: "2" } }, + { cable: "__RUN_2", wire: 1, from: { connector: "__WAGO_1", pin: "1" }, to: null }, + ], + mates: [{ from: "PLUG", to: "SOCKET" }], +}; + +const md = tables(harness); +const section = (heading: string) => { + const start = md.indexOf(heading); + assert.ok(start >= 0, `no ${heading}`); + const next = md.indexOf("\n#", start + heading.length); + return md.slice(start, next < 0 ? undefined : next); +}; +const rows = (text: string) => text.split("\n").filter((l) => l.startsWith("| ") && !l.startsWith("| ---")).slice(1); + +test("the cut list has one row per core, with both ends joined across connection sets", () => { + const cut = rows(section("## Cut list")); + assert.deepEqual(cut, [ + "| LOOM | 1 PUMP | RD | 1.5 mm² | 2 m | PANEL 1 · PUMP | PLUG 1 · PUMP |", + "| LOOM | 2 GND | GNYE | 1.5 mm² | 2 m | PANEL 2 · GND | WAGO |", + "| RUN · 1 | 1 | BK | – | – | WAGO | PLUG 2 · GND |", + "| RUN · 2 | 1 | BK | – | – | WAGO | – |", + ]); +}); + +test("a connector's pinout lists every pin, the wire on it and where that wire goes", () => { + const plug = section("### PLUG"); + assert.match(plug, /Deutsch DT · mates with SOCKET/); + assert.deepEqual(rows(plug), [ + "| 1 | PUMP | LOOM core 1 (RD) | PANEL 1 · PUMP |", + "| 2 | GND | RUN · 1 (BK) | WAGO |", + "| | | looped to 3 | |", + "| 3 | SPARE | looped to 2 | |", + ]); +}); + +test("a pin with several wires gets a row for each", () => { + assert.deepEqual(rows(section("### WAGO")), [ + "| – | | LOOM core 2 (GNYE) | PANEL 2 · GND |", + "| | | RUN · 1 (BK) | PLUG 2 · GND |", + "| | | RUN · 2 (BK) | – |", + ]); +}); + +test("an unused pin is listed with no wire", () => { + assert.deepEqual(rows(section("### SOCKET")), ["| 1 | | – | |"]); +}); + +test("a pipe in a label can't break the table", () => { + const h: Harness = { ...harness, connectors: [conn("X", [pin("1", "A|B")])], cables: [], links: [], mates: [] }; + assert.match(tables(h), /\| 1 \| A\\\|B \|/); +}); diff --git a/test/wireviz.test.ts b/test/wireviz.test.ts index ca3ff51..2cb0297 100644 --- a/test/wireviz.test.ts +++ b/test/wireviz.test.ts @@ -124,3 +124,99 @@ test("cable notes are read", () => { const withNotes = loom.replace(' wirelabels: ["24V", "GND"]\n', ' wirelabels: ["24V", "GND"]\n notes: |\n Verify gauge\n'); assert.deepEqual(readWireviz([shared, withNotes], "x").cables.find((c) => c.id === "TWIN")!.notes, ["Verify gauge"]); }); + +// Syntax the other Hailey looms use, matched against how the WireViz fork reads it. +const more = ` +connectors: + DT: + pinlabels: ["A", "B", "C", "D"] + pincolors: ["RD", "BK", "GNYE", ""] + PLUG: + pincount: 1 + pinlabels: ["ETH"] + LAMP: + pinlabels: ["DI", "BI", "BO", "GND"] + loops: [[2, 3]] +cables: + QUAD: + wirecount: 4 + colors: ["RD", "BK", "YE", "WH"] + wirelabels: ["V1", "GND", "D1", "D2"] + ETH: + wirecount: 1 + colors: ["GY"] +connections: + - + - DT: [1-4] + - QUAD: [1-4] + - DT.DT_B: [1-4] + - + - PLUG + - ETH. + - LAMP: [DI] + - + - DT.DT_B + - [==] + - DT.DT_C + - + - QUAD.QUAD_B: [V1, BK] + - LAMP: [BO, GND] +`; +const readMore = () => readWireviz([more], "More"); + +test("pin ranges expand, as WireViz's expand does", () => { + const quad = readMore().links.filter((l) => l.cable === "QUAD"); + assert.deepEqual(quad.map((l) => [l.from!.pin, l.wire, l.to!.pin]), [["1", 1, "1"], ["2", 2, "2"], ["3", 3, "3"], ["4", 4, "4"]]); +}); + +test("a bare connector name connects its pin 1", () => { + const eth = readMore().links.find((l) => l.cable.startsWith("__ETH"))!; + assert.deepEqual(eth.from, { connector: "PLUG", pin: "1" }); + assert.deepEqual(eth.to, { connector: "LAMP", pin: "1" }); +}); + +test("a set that starts with a cable leaves that end open", () => { + const b = readMore().links.filter((l) => l.cable === "QUAD_B"); + assert.deepEqual(b.map((l) => [l.from, l.wire, l.to?.pin]), [[null, 1, "3"], [null, 2, "4"]]); +}); + +test("a wire can be named by its colour code", () => { + const b = readMore().links.filter((l) => l.cable === "QUAD_B"); + assert.equal(b[1].wire, 2); +}); + +test("an == arrow mates two connectors as a whole", () => { + assert.deepEqual(readMore().mates, [{ from: "DT_B", to: "DT_C" }]); +}); + +test("loops and pin colours are read", () => { + const h = readMore(); + assert.deepEqual(h.connectors.find((c) => c.id === "LAMP")!.loops, [["2", "3"]]); + assert.deepEqual(h.connectors.find((c) => c.id === "DT")!.pins.map((p) => p.colours), [["RD"], ["BK"], ["GN", "YE"], []]); +}); + +test("a duplicate key takes the last value, as PyYAML does", () => { + const dup = `connectors:\n X:\n type: "one"\n type: "two"\n pinlabels: ["A"]\ncables:\n W:\n wirecount: 1\nconnections:\n -\n - X: [A]\n - W: [1]\n`; + assert.equal(readWireviz([dup], "x").connectors[0].type, "two"); +}); + +test("a fresh instance is one per entry, however many wires the entry names", () => { + const src = `connectors:\n A:\n pinlabels: ["X", "Y"]\ncables:\n TWIN:\n wirecount: 2\nconnections:\n -\n - A: [X, Y]\n - TWIN.: [1, 2]\n -\n - A: [X]\n - TWIN.: [1]\n`; + const twins = readWireviz([src], "x").cables.map((c) => c.id); + assert.deepEqual(twins, ["__TWIN_1", "__TWIN_2"]); +}); + +test("a bare fresh name repeated down a set is a new instance on every row, as in the fork", () => { + const src = `connectors:\n A:\n pinlabels: ["X", "Y"]\n B:\n pincount: 1\ncables:\n ONE:\n wirecount: 1\nconnections:\n -\n - A: [X, Y]\n - ONE.: [1, 1]\n - B.\n`; + assert.deepEqual(readWireviz([src], "x").connectors.map((c) => c.id), ["A", "__B_1", "__B_2"]); +}); + +test("a loop on a pin the connector doesn't have is an error, as in the fork", () => { + const src = `connectors:\n A:\n pinlabels: ["X", "Y"]\n loops: [[1, 3]]\ncables:\n W:\n wirecount: 1\nconnections:\n -\n - A: [X]\n - W: [1]\n`; + assert.throws(() => readWireviz([src], "x"), /A.*loop pin 3/); +}); + +test("more pin labels than pincount is an error, not dropped labels", () => { + const src = `connectors:\n A:\n pincount: 1\n pinlabels: ["X", "Y"]\ncables:\n W:\n wirecount: 1\nconnections:\n -\n - A: [X]\n - W: [1]\n`; + assert.throws(() => readWireviz([src], "x"), /A.*2 pin labels.*1 pin/); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..5a30592 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "rewriteRelativeImportExtensions": true + }, + "include": ["src"] +}