From f9a5ba87ea4a634b21e65aa9cb727b6b5429dbe8 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 11:54:54 +1000 Subject: [PATCH 01/14] Read the rest of the WireViz syntax Hailey uses Pin ranges, bare connector names, sets that start or end with a cable, wires named by colour code, == mates, loops, pin colours, and duplicate keys, all read the way the hailey-v1 fork reads them. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/model.ts | 13 ++++ src/wireviz.ts | 146 ++++++++++++++++++++++++++++++------------- test/sheet.test.ts | 9 +-- test/wireviz.test.ts | 75 ++++++++++++++++++++++ 4 files changed, 196 insertions(+), 47 deletions(-) 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/wireviz.ts b/src/wireviz.ts index 6de96d2..349ae7d 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,109 @@ 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)); + const [designator, refs] = Object.entries(e as Yaml)[0]; + return expand(refs).map((ref) => item(designator, 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); 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]) : [], + })), + loops: (def.loops ?? []).map((pair: unknown[]) => { + if (pair.length !== 2) throw new Error(`${id}: a loop joins exactly two pins`); + return [String(pair[0]), String(pair[1])]; + }), simple, accent: hex(def.bgcolor_title ?? def.bgcolor), notes: lines(def.notes), @@ -110,6 +165,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 +173,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/sheet.test.ts b/test/sheet.test.ts index 1b7eb7c..3c48a60 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); diff --git a/test/wireviz.test.ts b/test/wireviz.test.ts index ca3ff51..7065e34 100644 --- a/test/wireviz.test.ts +++ b/test/wireviz.test.ts @@ -124,3 +124,78 @@ 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"); +}); From 3073488e3eef946812aa336623fafd48a3cd3b0a Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 11:55:55 +1000 Subject: [PATCH 02/14] Check every loom's connections against the WireViz fork Co-Authored-By: Claude Opus 5.5 (1M context) --- parity/run.ts | 82 ++++++++++++++++++++++++++++++++++++++++++ parity/wireviz_dump.py | 51 ++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 parity/run.ts create mode 100644 parity/wireviz_dump.py diff --git a/parity/run.ts b/parity/run.ts new file mode 100644 index 0000000..0afc4a3 --- /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 by its template, as the dump names it. */ +const name = (id: string) => /^__(.+)_\d+$/.exec(id)?.[1].concat(".") ?? id; +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", "ignore"], + }), + ); + 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..1396b22 --- /dev/null +++ b/parity/wireviz_dump.py @@ -0,0 +1,51 @@ +#!/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 by its template, so the two readers' numbering can't differ.""" + m = FRESH.match(designator) + return f"{m.group(1)}." 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:]) From bf2d3a675af5218d2740172883152120428cd86b Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:00:38 +1000 Subject: [PATCH 03/14] Add the CLI, and draw mates, loops, pin colours and junctions ferrule [--prepend FILE]... [--format svg,png] [--output-dir DIR] LOOM.yml writes an SVG that follows the viewer's theme and a dark PNG. Inter is measured with HarfBuzz, so kerning counts, and subset into each SVG, so no viewer falls back to another font. resvg gets the same faces as TTF. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 110 ++++++++++++++++++++++++++++++++++-------- package.json | 5 +- spike/run.ts | 6 +-- src/cli.ts | 87 +++++++++++++++++++++++++++++++++ src/measure.ts | 99 +++++++++++++++++++++++++++---------- src/render.ts | 103 +++++++++++++++++++++++++++++++-------- src/sheet.ts | 51 +++++++++++++++----- src/types/vendor.d.ts | 12 +++++ test/metrics.test.ts | 2 +- test/sheet.test.ts | 43 +++++++++++++++++ 10 files changed, 436 insertions(+), 82 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/types/vendor.d.ts diff --git a/package-lock.json b/package-lock.json index e1e3d4c..91c20ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { - "name": "155-layout-spike", - "version": "1.0.0", + "name": "ferrule", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "155-layout-spike", - "version": "1.0.0", - "license": "ISC", + "name": "ferrule", + "version": "0.0.0", "dependencies": { "@fontsource/inter": "^5.3.0", "@resvg/resvg-js": "^2.6.2", "elkjs": "^0.12.0", - "opentype.js": "^2.0.0", + "fontverter": "^2.0.0", + "harfbuzzjs": "^1.6.2", + "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" } }, @@ -255,13 +255,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 +595,64 @@ "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/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 +697,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 +733,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..1eb92d6 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "@fontsource/inter": "^5.3.0", "@resvg/resvg-js": "^2.6.2", "elkjs": "^0.12.0", - "opentype.js": "^2.0.0", + "fontverter": "^2.0.0", + "harfbuzzjs": "^1.6.2", + "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" } } 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..42ee976 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,87 @@ +#!/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] [--output-dir DIR] LOOM.yml... +// +// Writes .svg, which follows the viewer's light or dark mode, and .png, rendered dark. + +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 { readWireviz } from "./wireviz.ts"; + +const FORMATS = ["svg", "png"] 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] [--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 sheet = buildSheet(readWireviz([...prepended, readFileSync(loom, "utf8")], name)); + 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/measure.ts b/src/measure.ts index b25f994..1a2e87d 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -1,34 +1,83 @@ -// 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. Fold rows are the one italic. */ +export const FACES = [ + { weight: 400, style: "normal" }, + { weight: 500, style: "normal" }, + { weight: 600, style: "normal" }, + { weight: 400, style: "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 @fontsource ships WOFF2. +const loaded = new Map(); +for (const f of FACES) { + const woff2 = readFileSync(require.resolve(`@fontsource/inter/files/inter-latin-${f.weight}-${f.style}.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/render.ts b/src/render.ts index 0d5095a..83f9a49 100644 --- a/src/render.ts +++ b/src/render.ts @@ -4,12 +4,15 @@ 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": "#4c535d", "tag-bg": "#ffffff", }, dark: { bg: "#121418", card: "#1c1f25", line: "#323741", text: "#e8eaed", muted: "#9aa1ab", faint: "#626a75", @@ -39,12 +42,28 @@ 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} +.mate{fill:none;stroke:var(--muted);stroke-linejoin:round} +.mate-gap{fill:none;stroke:var(--bg);stroke-linejoin: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,25 +74,28 @@ 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, 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); @@ -84,43 +106,82 @@ export function render(sheet: Sheet, place: Placement, caption: string): string if (!wire) return; o.push(...drawWire(`M${n(p.x)},${n(y)} L${n(p.x + c.w)},${n(y)}`, wire)); 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(text(g, `x="${n(p.x + 28)}" y="${n(p.y + 21)}" font-size="${S.titleSize}"`, c.title, 600)); + if (c.sub) o.push(text(g, `class="muted" x="${n(p.x + 12)}" y="${n(p.y + 39)}" font-size="${S.subSize}"`, c.sub)); + 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 = a === "WH" ? "case-wh" : "casing"; + const o = [``]; + if (b) o.push(``); + return o; +} + +/** Two connectors plugged together: a double line, so it can't be read as a wire. */ +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. + const busy = (side: string) => c.ports.some((q) => q.side === side && (q.id === `${a}:${side}` || q.id === `${b}:${side}`)); + const east = !busy("E") || busy("W"); + const x = east ? p.x + c.w : p.x; + const out = east ? x + 14 : x - 14; + 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) => ``), + ]; +} + function drawWire(d: string, w: Wire): string[] { const [c1, c2] = w.colours; const kase = c1 === "BK" ? " case-bk" : c1 === "WH" ? " case-wh" : ""; @@ -132,10 +193,10 @@ function drawWire(d: string, w: Wire): string[] { 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), ]; } diff --git a/src/sheet.ts b/src/sheet.ts index ec6609f..16f0752 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -21,12 +21,16 @@ export const STYLE = { 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; } @@ -51,6 +55,8 @@ export interface Card { rowTop: number; rowH: number; rows: Row[]; + /** Pins bridged on the card itself, by row key. */ + loops: [string, string][]; notes: string[]; ports: Port[]; } @@ -63,6 +69,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 +101,18 @@ 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)) { + if (!l.from || !l.to) throw new Error(`${c.id}: a run needs both ends`); wires.push({ id: `w${i}`, from: touch(l.from.connector, l.from.pin, "E"), @@ -114,9 +127,18 @@ 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 }); + } + 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)); + } + for (const c of h.cables) if (!isRun(c)) cards.push(cableCard(c, used.get(c.id) ?? new Map())); return { title: h.title, cards, wires }; } @@ -140,7 +162,7 @@ function fmt(n: number): string { return String(Number(n.toFixed(2))); } -function connectorCard(c: Connector, used: Map>): Card { +function connectorCard(c: Connector, used: Map>, inUse: Set): Card { const sub = [c.type, c.subtype].filter(Boolean).join(" · "); if (c.simple) { const w = width([textWidth(c.id, STYLE.titleSize, 600) + 40, textWidth(sub, STYLE.subSize) + 24]); @@ -159,14 +181,17 @@ 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", c.id, sub, c.accent, 0, rows, c.notes); + card.loops = c.loops; + return finish(card, used); } function cableCard(c: Cable, used: Map>): Card { @@ -177,19 +202,21 @@ function cableCard(c: Cable, used: Map>): Card { } 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, sub, accent, w, h: 0, rowTop: STYLE.headH, rowH: STYLE.rowH, rows, loops: [], notes, ports: [] }; } function finish(card: Card, used: Map>): Card { const s = STYLE; + const swatch = card.rows.some((r) => r.colours) ? s.swatchW : 0; 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.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)), ]); 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.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 +224,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) })), ); 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/test/metrics.test.ts b/test/metrics.test.ts index f4ae734..6bf7df1 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, sub: "", 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/sheet.test.ts b/test/sheet.test.ts index 3c48a60..6805020 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -80,3 +80,46 @@ 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]); +}); From ffdcc401638accd07ef74ad80ed5ee3ceb105478 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:01:44 +1000 Subject: [PATCH 04/14] Build to dist so ferrule installs from a git tag Node won't strip types under node_modules, so a git install runs prepare and gets compiled JavaScript. Dependencies are pinned exactly: a consumer's lockfile resolves them from these ranges, and output bytes depend on them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 19 +++++++++++++++++++ .gitignore | 1 + package-lock.json | 28 +++++++++++++++++----------- package.json | 34 +++++++++++++++++++++++----------- tsconfig.build.json | 10 ++++++++++ 5 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tsconfig.build.json 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/package-lock.json b/package-lock.json index 91c20ce..3bd46d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,30 @@ { "name": "ferrule", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ferrule", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { - "@fontsource/inter": "^5.3.0", - "@resvg/resvg-js": "^2.6.2", - "elkjs": "^0.12.0", - "fontverter": "^2.0.0", - "harfbuzzjs": "^1.6.2", - "subset-font": "^2.9.0", - "yaml": "^2.9.1" + "@fontsource/inter": "5.3.0", + "@resvg/resvg-js": "2.6.2", + "elkjs": "0.12.0", + "fontverter": "2.0.0", + "harfbuzzjs": "1.6.2", + "subset-font": "2.9.0", + "yaml": "2.9.1" + }, + "bin": { + "ferrule": "dist/cli.js" }, "devDependencies": { - "@types/node": "^26.6.3", - "typescript": "^7.0.2" + "@types/node": "26.6.3", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@fontsource/inter": { diff --git a/package.json b/package.json index 1eb92d6..1507978 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,37 @@ { "name": "ferrule", - "version": "0.0.0", + "version": "0.1.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", - "fontverter": "^2.0.0", - "harfbuzzjs": "^1.6.2", - "subset-font": "^2.9.0", - "yaml": "^2.9.1" + "@fontsource/inter": "5.3.0", + "@resvg/resvg-js": "2.6.2", + "elkjs": "0.12.0", + "fontverter": "2.0.0", + "harfbuzzjs": "1.6.2", + "subset-font": "2.9.0", + "yaml": "2.9.1" }, "devDependencies": { - "@types/node": "^26.6.3", - "typescript": "^7.0.2" + "@types/node": "26.6.3", + "typescript": "7.0.2" } } 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"] +} From 7ddf6dc60c9fcb0cd351f5d41da14bdd48eb9c89 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:04:40 +1000 Subject: [PATCH 05/14] Make one fresh instance per entry, as the fork does TWIN.: [1, 2] is both cores of one new cable, not two cables. The parity check now compares instance numbers too, which is what would have caught it. Co-Authored-By: Claude Opus 5.5 (1M context) --- parity/run.ts | 4 ++-- parity/wireviz_dump.py | 7 ++++--- src/wireviz.ts | 4 +++- test/wireviz.test.ts | 11 +++++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/parity/run.ts b/parity/run.ts index 0afc4a3..758d0ae 100644 --- a/parity/run.ts +++ b/parity/run.ts @@ -28,8 +28,8 @@ interface Dump { loops: Record; } -/** An autogenerated instance by its template, as the dump names it. */ -const name = (id: string) => /^__(.+)_\d+$/.exec(id)?.[1].concat(".") ?? id; +/** 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 { diff --git a/parity/wireviz_dump.py b/parity/wireviz_dump.py index 1396b22..a382621 100644 --- a/parity/wireviz_dump.py +++ b/parity/wireviz_dump.py @@ -15,13 +15,14 @@ from wireviz.wireviz import parse from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX -FRESH = re.compile(rf"^{AUTOGENERATED_PREFIX}(.+)_\d+$") +FRESH = re.compile(rf"^{AUTOGENERATED_PREFIX}(.+)_(\d+)$") def name(designator): - """An autogenerated instance by its template, so the two readers' numbering can't differ.""" + """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)}." if m else designator + return f"{m.group(1)}.{m.group(2)}" if m else designator def main(prepend, loom): diff --git a/src/wireviz.ts b/src/wireviz.ts index 349ae7d..e15d550 100644 --- a/src/wireviz.ts +++ b/src/wireviz.ts @@ -59,8 +59,10 @@ export function readWireviz(sources: string[], title: string): Harness { 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]; - return expand(refs).map((ref) => item(designator, ref)); + const one = item(designator, 0); + return expand(refs).map((ref) => ({ ...one, ref })); }); columns.forEach((col, i) => { if (i && (col[0].kind === "connector") === (columns[i - 1][0].kind === "connector")) { diff --git a/test/wireviz.test.ts b/test/wireviz.test.ts index 7065e34..a207c1b 100644 --- a/test/wireviz.test.ts +++ b/test/wireviz.test.ts @@ -199,3 +199,14 @@ 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"]); +}); From 302036abb7a2ee1cdc3fa15d25a7a6bbedc57a79 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:07:20 +1000 Subject: [PATCH 06/14] Act on the first parity review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full Inter faces from inter-ui, so Ω and → draw instead of a box. - Black cores are dark grey on the dark theme, so they don't look hollow; mates are a chain of dots, which no wire is drawn as. - Autogenerated connectors are titled by their template. - Subtitles wrap inside the card; notes rejoin hand-wrapped lines into paragraphs and may widen a card to its limit. - Tags sit on their wire; an unterminated core ends in a cap; colour chips have an outline so white ones show on a white card. - Wider spacing between parallel wires, and a sheet wider than 2.2:1 is wrapped into rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/layout/elk.ts | 21 +++++++++++++-- src/measure.ts | 17 +++++++----- src/render.ts | 63 +++++++++++++++++++++++++++++++++++--------- src/sheet.ts | 55 ++++++++++++++++++++++++++++---------- test/metrics.test.ts | 2 +- test/render.test.ts | 54 +++++++++++++++++++++++++++++++++++++ test/sheet.test.ts | 22 ++++++++++++++++ 7 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 test/render.test.ts diff --git a/src/layout/elk.ts b/src/layout/elk.ts index 7c72b5d..f7defb3 100644 --- a/src/layout/elk.ts +++ b/src/layout/elk.ts @@ -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,7 +32,23 @@ export const ELK_OPTIONS: Record = { "elk.layered.thoroughness": "40", }; +/** Wider than this, a sheet is wrapped into rows: a long chain is unreadable on a phone. */ +export const WRAP_ABOVE = 2.2; + +/** Wrapping options. Applied only past WRAP_ABOVE, because it adds crossings to a sheet that fits. */ +export const WRAP_OPTIONS: Record = { + "elk.layered.wrapping.strategy": "MULTI_EDGE", + "elk.aspectRatio": "1.6", +}; + +/** Lay out a sheet, wrapping it into rows if it comes out too wide to read on a phone. */ export async function layoutElk(sheet: Sheet, options: Record = {}): Promise { + const flat = await layoutOnce(sheet, options); + if (flat.width / flat.height <= WRAP_ABOVE) return flat; + return layoutOnce(sheet, { ...WRAP_OPTIONS, ...options }); +} + +async function layoutOnce(sheet: Sheet, options: Record): Promise { const graph: ElkNode = { id: "root", layoutOptions: { ...ELK_OPTIONS, ...options }, diff --git a/src/measure.ts b/src/measure.ts index 1a2e87d..ae5e86d 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -10,12 +10,15 @@ import subsetFont from "subset-font"; const require = createRequire(import.meta.url); -/** The faces the Instrument style draws with. Fold rows are the one italic. */ +/** + * 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" }, - { weight: 500, style: "normal" }, - { weight: 600, style: "normal" }, - { weight: 400, style: "italic" }, + { 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]; @@ -28,10 +31,10 @@ interface Loaded { upem: number; } -// Converted once at load: HarfBuzz and resvg both need TrueType, and @fontsource ships WOFF2. +// 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(`@fontsource/inter/files/inter-latin-${f.weight}-${f.style}.woff2`)); + 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 }); diff --git a/src/render.ts b/src/render.ts index 83f9a49..0fcfa72 100644 --- a/src/render.ts +++ b/src/render.ts @@ -13,10 +13,13 @@ export const THEMES = { light: { bg: "#eef0f3", card: "#ffffff", line: "#d5d9df", text: "#1b1f24", muted: "#5e6670", faint: "#9aa1aa", casing: "#7f8792", "case-bk": "#7f8792", "case-wh": "#4c535d", "tag-bg": "#ffffff", + "core-bk": WIRE.BK, "core-wh": WIRE.WH, }, 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; @@ -43,8 +46,9 @@ text{font-family:Inter,'Helvetica Neue',Arial,sans-serif;fill:var(--text)} .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} -.mate{fill:none;stroke:var(--muted);stroke-linejoin:round} -.mate-gap{fill:none;stroke:var(--bg);stroke-linejoin:round}`; +.chip{stroke:var(--line);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); @@ -90,7 +94,7 @@ export async function render(sheet: Sheet, place: Placement, caption = ""): Prom continue; } o.push(...drawWire(path(r.kind, pts), w)); - if (w.tag && r.tag) tags.push(...drawTag(g, 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(``); } @@ -101,10 +105,15 @@ export async function render(sheet: Sheet, place: Placement, caption = ""): Prom 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(g, { x: p.x + c.w / 2, y }, label, tagWidth(label))); }); @@ -122,9 +131,11 @@ 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(``); o.push(text(g, `x="${n(p.x + 28)}" y="${n(p.y + 21)}" font-size="${S.titleSize}"`, c.title, 600)); - if (c.sub) o.push(text(g, `class="muted" x="${n(p.x + 12)}" y="${n(p.y + 39)}" font-size="${S.subSize}"`, c.sub)); + 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) => { @@ -156,14 +167,14 @@ function drawSwatch(x: number, cy: number, colours: string[]): string[] { const y = cy - size / 2; const [a, b] = colours; const kase = a === "WH" ? "case-wh" : "casing"; - const o = [``]; - if (b) o.push(``); + const o = [``]; + if (b) o.push(``); return o; } -/** Two connectors plugged together: a double line, so it can't be read as a wire. */ +/** Two connectors plugged together: a chain of dots, which no wire is drawn as. */ function drawMate(d: string): string[] { - return [``, ``]; + return [``]; } /** A bridge between two pins of one connector, drawn as a short grey wire looping off its side. */ @@ -182,14 +193,21 @@ function drawLoop(c: Card, p: Pt, [a, b]: [string, string]): string[] { ]; } +/** 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 o = [ ``, - ``, + ``, ]; - if (c2) o.push(``); + if (c2) o.push(``); return o; } @@ -200,6 +218,25 @@ function drawTag(g: Glyphs, c: Pt, label: string, w: number): string[] { ]; } +/** + * 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 16f0752..1b448cc 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -18,6 +18,7 @@ export const STYLE = { rowSize: 11.5, noteSize: 10, noteLh: 14, + subLh: 13, tagSize: 10, tagH: 17, foldFrom: 3, @@ -48,7 +49,8 @@ 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; @@ -164,11 +166,13 @@ function fmt(n: number): string { function connectorCard(c: Connector, used: Map>, inUse: Set): Card { const sub = [c.type, c.subtype].filter(Boolean).join(" · "); + const title = c.id.startsWith("__") ? c.template : c.id; 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[] = []; @@ -189,7 +193,7 @@ function connectorCard(c: Connector, used: Map>, inUse: Set>): Card { } 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, loops: [], 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.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); @@ -237,13 +251,26 @@ 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 into paragraphs. A YAML note is often wrapped by hand, and wrapping it + * again line by line leaves orphans; a line starting with a bullet or a number starts a new one. + */ +function paragraphs(lines: string[]): string[] { + const out: string[] = []; + for (const line of lines) { + if (out.length && !/^([-*•]|\d+[.)])\s/.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/test/metrics.test.ts b/test/metrics.test.ts index 6bf7df1..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: [], loops: [], 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..d4ddae7 --- /dev/null +++ b/test/render.test.ts @@ -0,0 +1,54 @@ +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/); + } +}); diff --git a/test/sheet.test.ts b/test/sheet.test.ts index 6805020..a590e82 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -123,3 +123,25 @@ test("looped pins stay unfolded and the loop is kept on the card", () => { test("pin colours reach the rows", () => { assert.deepEqual(card2("Q").rows.map((r) => r.colours), [undefined, ["GN", "YE"], undefined, undefined]); }); + +test("an autogenerated connector is titled by its template", () => { + const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, id: "__LAMP_2", template: "LAMP" } : c)), + links: harness.links.map((l) => (l.to?.connector === "B" ? { ...l, to: { ...l.to, connector: "__LAMP_2" } } : l)) }; + assert.equal(buildSheet(h).cards.find((c) => c.id === "__LAMP_2")!.title, "LAMP"); +}); + +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"]); +}); From 1d0d7ab3aab679724fb27b991e959bbc6f13d9b5 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:07:56 +1000 Subject: [PATCH 07/14] Document the CLI, the syntax it reads, and the parity check Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bf6d95f..112242d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,29 @@ 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.1.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. + +**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 +56,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 +89,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 +104,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,7 +114,7 @@ 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. +- [x] **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. - [ ] **Plumbing.** Pipe runs, fittings and tanks on the same core. From 755163fd3e2605beda3a45a99bdbdeae0e3feb4b Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:10:28 +1000 Subject: [PATCH 08/14] Act on the second parity review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A mated pair is laid out as one node, so a wrap can't split it. - Only a sheet wider than 3:1 wraps: wrapping sends wires round the edge. - Notes join only a lowercase continuation; other line breaks are kept. - A loop with wires on both sides is a bracket inside the card. - Repeated parts are titled TEMPLATE · n; white cores are pale grey on the light theme; chip outlines are darker. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/layout/elk.ts | 94 +++++++++++++++++++++++++++++++++++++-------- src/render.ts | 21 ++++++---- src/sheet.ts | 20 +++++++--- test/render.test.ts | 10 +++++ test/sheet.test.ts | 8 +++- 5 files changed, 123 insertions(+), 30 deletions(-) diff --git a/src/layout/elk.ts b/src/layout/elk.ts index f7defb3..1c402bf 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 = { @@ -32,8 +32,42 @@ export const ELK_OPTIONS: Record = { "elk.layered.thoroughness": "40", }; -/** Wider than this, a sheet is wrapped into rows: a long chain is unreadable on a phone. */ -export const WRAP_ABOVE = 2.2; +/** + * Wider than this, a sheet is wrapped into rows: a long chain is unreadable on a phone. Wrapping + * sends wires between rows round the sheet's edge, so it is kept for sheets that really need it. + */ +export const WRAP_ABOVE = 3; + +/** 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 can't be split by a wrap, 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; +} /** Wrapping options. Applied only past WRAP_ABOVE, because it adds crossings to a sheet that fits. */ export const WRAP_OPTIONS: Record = { @@ -49,24 +83,39 @@ export async function layoutElk(sheet: Sheet, options: Record = } async function layoutOnce(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}`], @@ -77,6 +126,17 @@ async function layoutOnce(sheet: Sheet, options: Record): Promis 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/render.ts b/src/render.ts index 0fcfa72..312c294 100644 --- a/src/render.ts +++ b/src/render.ts @@ -12,8 +12,9 @@ import { STYLE as S, tagWidth, type Card, type Sheet, type Wire } from "./sheet. export const THEMES = { light: { bg: "#eef0f3", card: "#ffffff", line: "#d5d9df", text: "#1b1f24", muted: "#5e6670", faint: "#9aa1aa", - casing: "#7f8792", "case-bk": "#7f8792", "case-wh": "#4c535d", "tag-bg": "#ffffff", - "core-bk": WIRE.BK, "core-wh": WIRE.WH, + 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", @@ -46,7 +47,7 @@ text{font-family:Inter,'Helvetica Neue',Arial,sans-serif;fill:var(--text)} .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} -.chip{stroke:var(--line);stroke-width:1} +.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}`; @@ -180,11 +181,17 @@ function drawMate(d: string): string[] { /** 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. + // 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 east = !busy("E") || busy("W"); - const x = east ? p.x + c.w : p.x; - const out = east ? x + 14 : x - 14; + 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 [ diff --git a/src/sheet.ts b/src/sheet.ts index 1b448cc..bfa8a6f 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -164,9 +164,18 @@ function fmt(n: number): string { return String(Number(n.toFixed(2))); } +/** + * 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. + */ +function displayName(id: string, template: string): string { + const m = /^__.+_(\d+)$/.exec(id); + return m ? `${template} · ${m[1]}` : id; +} + function connectorCard(c: Connector, used: Map>, inUse: Set): Card { const sub = [c.type, c.subtype].filter(Boolean).join(" · "); - const title = c.id.startsWith("__") ? c.template : c.id; + const title = displayName(c.id, c.template); if (c.simple) { 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, [], []); @@ -199,7 +208,7 @@ function connectorCard(c: Connector, used: Map>, inUse: Set>): Card { - const title = c.id.startsWith("__") ? c.template : c.id; + const title = displayName(c.id, c.template); 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); @@ -252,13 +261,14 @@ function width(candidates: number[]): number { } /** - * Source lines rejoined into paragraphs. A YAML note is often wrapped by hand, and wrapping it - * again line by line leaves orphans; a line starting with a bullet or a number starts a new one. + * 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 && !/^([-*•]|\d+[.)])\s/.test(line)) out[out.length - 1] += ` ${line}`; + if (out.length && /^\p{Ll}/u.test(line)) out[out.length - 1] += ` ${line}`; else out.push(line); } return out; diff --git a/test/render.test.ts b/test/render.test.ts index d4ddae7..a159bfd 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -52,3 +52,13 @@ test("flattening leaves no CSS variable for a rasteriser to miss", async () => { assert.doesNotMatch(flat, /prefers-color-scheme/); } }); + +test("a mated pair is laid out side by side, so a wrap can't separate them", 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 a590e82..edb78b0 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -127,7 +127,7 @@ test("pin colours reach the rows", () => { test("an autogenerated connector is titled by its template", () => { const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, id: "__LAMP_2", template: "LAMP" } : c)), links: harness.links.map((l) => (l.to?.connector === "B" ? { ...l, to: { ...l.to, connector: "__LAMP_2" } } : l)) }; - assert.equal(buildSheet(h).cards.find((c) => c.id === "__LAMP_2")!.title, "LAMP"); + 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", () => { @@ -145,3 +145,9 @@ test("notes keep their bullets as paragraphs and rejoin wrapped source lines", ( 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"]); +}); From fa3729b19aea2cd48550e3f8e699b7aa4d0729ea Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:11:19 +1000 Subject: [PATCH 09/14] Depend on inter-ui, which the full Inter faces come from Missed from the review fix that switched to them. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 20 ++++++++++---------- package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bd46d7..b077b6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,11 @@ "name": "ferrule", "version": "0.1.0", "dependencies": { - "@fontsource/inter": "5.3.0", "@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" }, @@ -27,15 +27,6 @@ "node": ">=22" } }, - "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" - } - }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -629,6 +620,15 @@ "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", diff --git a/package.json b/package.json index 1507978..a4c57c0 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "parity": "node parity/run.ts" }, "dependencies": { - "@fontsource/inter": "5.3.0", "@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" }, From 8bd30ac4aaecff33fef687c1623bd372b590141c Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:13:41 +1000 Subject: [PATCH 10/14] Keep wide sheets in one row, and number only repeated parts ELK's wrap cut through multi-core cables in every loom it touched, and the review rated those ribbons worse than a wide sheet. A part drawn once is titled by its template alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/layout/elk.ts | 26 +++++--------------------- src/sheet.ts | 24 +++++++++++++++--------- test/render.test.ts | 2 +- test/sheet.test.ts | 16 +++++++++++++--- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/layout/elk.ts b/src/layout/elk.ts index 1c402bf..6d83aa6 100644 --- a/src/layout/elk.ts +++ b/src/layout/elk.ts @@ -32,12 +32,6 @@ export const ELK_OPTIONS: Record = { "elk.layered.thoroughness": "40", }; -/** - * Wider than this, a sheet is wrapped into rows: a long chain is unreadable on a phone. Wrapping - * sends wires between rows round the sheet's edge, so it is kept for sheets that really need it. - */ -export const WRAP_ABOVE = 3; - /** The gap between the two halves of a mated pair. */ const MATE_GAP = 48; @@ -50,8 +44,8 @@ interface Pair { /** * 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 can't be split by a wrap, and its mate is drawn as the short link it is. + * 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])); @@ -69,20 +63,10 @@ function pairs(sheet: Sheet): Pair[] { return out; } -/** Wrapping options. Applied only past WRAP_ABOVE, because it adds crossings to a sheet that fits. */ -export const WRAP_OPTIONS: Record = { - "elk.layered.wrapping.strategy": "MULTI_EDGE", - "elk.aspectRatio": "1.6", -}; - -/** Lay out a sheet, wrapping it into rows if it comes out too wide to read on a phone. */ +// 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 flat = await layoutOnce(sheet, options); - if (flat.width / flat.height <= WRAP_ABOVE) return flat; - return layoutOnce(sheet, { ...WRAP_OPTIONS, ...options }); -} - -async function layoutOnce(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) => ({ diff --git a/src/sheet.ts b/src/sheet.ts index bfa8a6f..1c04895 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -133,14 +133,18 @@ export function buildSheet(h: Harness): Sheet { 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) { 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)); + 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())); + 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 }; } @@ -166,16 +170,18 @@ function fmt(n: number): string { /** * 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. + * `REAR_LIGHT · 2`, so two of the same part can be told apart. A part drawn once is just its + * template. */ -function displayName(id: string, template: string): string { +function displayName(id: string, template: string, repeated: Set): string { const m = /^__.+_(\d+)$/.exec(id); - return m ? `${template} · ${m[1]}` : id; + if (!m) return id; + return repeated.has(template) ? `${template} · ${m[1]}` : template; } -function connectorCard(c: Connector, used: Map>, inUse: Set): Card { +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); + const title = displayName(c.id, c.template, repeated); if (c.simple) { 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, [], []); @@ -207,8 +213,8 @@ function connectorCard(c: Connector, used: Map>, inUse: Set>): Card { - const title = displayName(c.id, c.template); +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); diff --git a/test/render.test.ts b/test/render.test.ts index a159bfd..b792532 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -53,7 +53,7 @@ test("flattening leaves no CSS variable for a rasteriser to miss", async () => { } }); -test("a mated pair is laid out side by side, so a wrap can't separate them", async () => { +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); diff --git a/test/sheet.test.ts b/test/sheet.test.ts index edb78b0..fda21db 100644 --- a/test/sheet.test.ts +++ b/test/sheet.test.ts @@ -124,9 +124,11 @@ test("pin colours reach the rows", () => { assert.deepEqual(card2("Q").rows.map((r) => r.colours), [undefined, ["GN", "YE"], undefined, undefined]); }); -test("an autogenerated connector is titled by its template", () => { - const h: Harness = { ...harness, connectors: harness.connectors.map((c) => (c.id === "B" ? { ...c, id: "__LAMP_2", template: "LAMP" } : c)), - links: harness.links.map((l) => (l.to?.connector === "B" ? { ...l, to: { ...l.to, connector: "__LAMP_2" } } : l)) }; +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"); }); @@ -151,3 +153,11 @@ test("notes join only a lowercase continuation, so separate items stay separate" 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); +}); From daf9e37faa63c6a17bd9014ef220f3443d0468d9 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:16:11 +1000 Subject: [PATCH 11/14] Act on the code review - Reject a loop on a pin the connector lacks, and more pin labels than pincount, rather than drawing them wrong or dropping labels. - One rule picks a black or white core's casing, for wires and swatches. - The parity check passes the fork's errors through. - The README says what the parity check does and doesn't compare. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- parity/run.ts | 2 +- src/render.ts | 9 +++++++-- src/sheet.ts | 3 +-- src/wireviz.ts | 8 +++++++- test/wireviz.test.ts | 10 ++++++++++ 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 112242d..fab30a4 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ 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. -- [x] **Parity.** Render a full set of real looms until every one is correct, then compare them byte for byte in CI. +- [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. - [ ] **New outputs.** Pinout tables per connector in Markdown, a cut list, and gauge checks against terminal and fuse limits. - [ ] **Plumbing.** Pipe runs, fittings and tanks on the same core. diff --git a/parity/run.ts b/parity/run.ts index 758d0ae..013329a 100644 --- a/parity/run.ts +++ b/parity/run.ts @@ -65,7 +65,7 @@ for (const loom of looms) { 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", "ignore"], + stdio: ["ignore", "pipe", "inherit"], }), ); const ours = lines(fromFerrule(readWireviz([shared, readFileSync(src, "utf8")], loom))); diff --git a/src/render.ts b/src/render.ts index 312c294..958a22c 100644 --- a/src/render.ts +++ b/src/render.ts @@ -167,7 +167,7 @@ function drawSwatch(x: number, cy: number, colours: string[]): string[] { const size = 8; const y = cy - size / 2; const [a, b] = colours; - const kase = a === "WH" ? "case-wh" : "casing"; + const kase = `casing${casing(a)}`; const o = [``]; if (b) o.push(``); return o; @@ -200,6 +200,11 @@ function drawLoop(c: Card, p: Pt, [a, b]: [string, string]): string[] { ]; } +/** 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)"; @@ -209,7 +214,7 @@ function coreColour(code: string): string { 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 = [ ``, ``, diff --git a/src/sheet.ts b/src/sheet.ts index 1c04895..cf20135 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -113,8 +113,7 @@ export function buildSheet(h: Harness): Sheet { 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 (isRun(c)) { - if (!l.from || !l.to) throw new Error(`${c.id}: a 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"), diff --git a/src/wireviz.ts b/src/wireviz.ts index e15d550..60c363b 100644 --- a/src/wireviz.ts +++ b/src/wireviz.ts @@ -134,6 +134,7 @@ function connector(id: string, template: string, def: Yaml): Connector { const labels: string[] = (def.pinlabels ?? []).map(String); 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, @@ -144,9 +145,14 @@ function connector(id: string, template: string, def: Yaml): Connector { 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`); - return [String(pair[0]), String(pair[1])]; + 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), diff --git a/test/wireviz.test.ts b/test/wireviz.test.ts index a207c1b..2cb0297 100644 --- a/test/wireviz.test.ts +++ b/test/wireviz.test.ts @@ -210,3 +210,13 @@ test("a bare fresh name repeated down a set is a new instance on every row, as i 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/); +}); From ed3d4121f08317173dd38a0fbc5bf2e2dde3a4cf Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:37:55 +1000 Subject: [PATCH 12/14] Write a cut list and connector pinouts as Markdown `--format md` adds .md beside the diagrams: one row per core with its size, length and both ends, joined up across connection sets, and a table for each connector listing every pin, its wire and where that wire goes. GitHub renders both on a phone, where a wide diagram is hard to follow pin by pin. Hailey #155 step 5. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- src/cli.ts | 16 +++++-- src/sheet.ts | 4 +- src/tables.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++ test/tables.test.ts | 83 +++++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/tables.ts create mode 100644 test/tables.test.ts diff --git a/README.md b/README.md index fab30a4..691d4bd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ 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. +`--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`, diff --git a/src/cli.ts b/src/cli.ts index 42ee976..6340056 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,9 +2,10 @@ // 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] [--output-dir DIR] LOOM.yml... +// 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"; @@ -15,16 +16,17 @@ 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"] as const; +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] [--output-dir DIR] LOOM.yml..."; +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({ @@ -56,7 +58,13 @@ async function main(argv: string[]): Promise { const out = values["output-dir"] ?? dirname(loom); mkdirSync(out, { recursive: true }); - const sheet = buildSheet(readWireviz([...prepended, readFileSync(loom, "utf8")], name)); + 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)) { diff --git a/src/sheet.ts b/src/sheet.ts index cf20135..bdedc91 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -163,7 +163,7 @@ 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))); } @@ -172,7 +172,7 @@ function fmt(n: number): string { * `REAR_LIGHT · 2`, so two of the same part can be told apart. A part drawn once is just its * template. */ -function displayName(id: string, template: string, repeated: Set): string { +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; diff --git a/src/tables.ts b/src/tables.ts new file mode 100644 index 0000000..810ca78 --- /dev/null +++ b/src/tables.ts @@ -0,0 +1,103 @@ +// 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 drawn more than once are numbered, as on the diagram. + 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) { + const wire = `${name(core.cable)} ${core.wire} · ${colour(core)}`; + // A single core is named by its cable alone, as its own row in the cut list is. + const label = core.cable.wires.length === 1 ? `${name(core.cable)} · ${colour(core)}` : wire; + 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/test/tables.test.ts b/test/tables.test.ts new file mode 100644 index 0000000..fa0a382 --- /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 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 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 \|/); +}); From b43b51c74952e067ffff23b5c448aafbded364a8 Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:44:08 +1000 Subject: [PATCH 13/14] Name pinout wires unambiguously and release 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A number after a cable in a pinout now always means a core: single cores read `RUN · 1 (BK)` and multi-core cables `LOOM core 1 (RD)`. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/tables.ts | 10 ++++++---- test/tables.test.ts | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 691d4bd..345f7da 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Coming from WireViz? The format will feel familiar, and ferrule keeps compatibil ## Use ```bash -npm install github:XDGFX/ferrule#v0.1.0 +npm install github:XDGFX/ferrule#v0.2.0 npx ferrule --prepend shared.yml --output-dir diagrams src/*.yml ``` diff --git a/package-lock.json b/package-lock.json index b077b6b..d016f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ferrule", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ferrule", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@resvg/resvg-js": "2.6.2", "elkjs": "0.12.0", diff --git a/package.json b/package.json index a4c57c0..0db6a37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ferrule", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Wiring and plumbing diagrams from YAML", "type": "module", diff --git a/src/tables.ts b/src/tables.ts index 810ca78..a4e794a 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -18,7 +18,8 @@ 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 drawn more than once are numbered, as on the diagram. + // 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); @@ -75,9 +76,10 @@ export function tables(h: Harness): string { const here = { connector: c.id, pin: p.num }; const lines: [string, string][] = []; for (const core of order) { - const wire = `${name(core.cable)} ${core.wire} · ${colour(core)}`; - // A single core is named by its cable alone, as its own row in the cut list is. - const label = core.cable.wires.length === 1 ? `${name(core.cable)} · ${colour(core)}` : wire; + // 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)]); } diff --git a/test/tables.test.ts b/test/tables.test.ts index fa0a382..671c5e5 100644 --- a/test/tables.test.ts +++ b/test/tables.test.ts @@ -58,8 +58,8 @@ test("a connector's pinout lists every pin, the wire on it and where that wire g const plug = section("### PLUG"); assert.match(plug, /Deutsch DT · mates with SOCKET/); assert.deepEqual(rows(plug), [ - "| 1 | PUMP | LOOM 1 · RD | PANEL 1 · PUMP |", - "| 2 | GND | RUN · 1 · BK | WAGO |", + "| 1 | PUMP | LOOM core 1 (RD) | PANEL 1 · PUMP |", + "| 2 | GND | RUN · 1 (BK) | WAGO |", "| | | looped to 3 | |", "| 3 | SPARE | looped to 2 | |", ]); @@ -67,9 +67,9 @@ test("a connector's pinout lists every pin, the wire on it and where that wire g test("a pin with several wires gets a row for each", () => { assert.deepEqual(rows(section("### WAGO")), [ - "| – | | LOOM 2 · GNYE | PANEL 2 · GND |", - "| | | RUN · 1 · BK | PLUG 2 · GND |", - "| | | RUN · 2 · BK | – |", + "| – | | LOOM core 2 (GNYE) | PANEL 2 · GND |", + "| | | RUN · 1 (BK) | PLUG 2 · GND |", + "| | | RUN · 2 (BK) | – |", ]); }); From 1e37fee882aa160a6b4a0a1569c92d4c6260f35a Mon Sep 17 00:00:00 2001 From: xdgfx Date: Thu, 1 Oct 2026 12:44:20 +1000 Subject: [PATCH 14/14] Tick off the new outputs on the roadmap Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 345f7da..e3006cf 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ 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. - [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. -- [ ] **New outputs.** Pinout tables per connector in Markdown, a cut list, and gauge checks against terminal and fuse limits. +- [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