diff --git a/README.md b/README.md index e3006cf..5709009 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ 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. Wiring works: ferrule reads the WireViz subset one real project uses and draws all -> ten of its looms. Plumbing is not started. +> Pre-alpha. ferrule reads the WireViz subset and the pipeviz YAML that one real project uses, +> and draws all ten of its looms and all four of its plumbing plans. ## Use ```bash -npm install github:XDGFX/ferrule#v0.2.0 +npm install github:XDGFX/ferrule#v0.3.0 npx ferrule --prepend shared.yml --output-dir diagrams src/*.yml ``` @@ -46,6 +46,17 @@ for each connector. GitHub renders both tables on a phone. 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. +**Plumbing** is read from pipeviz YAML, chosen for any file with a top-level `components` or +`pipes` key. Read: prepended files, deep-merged under the plan's own `diagram`, `templates` and +`components`; `ref` and nested `template` chains; `ports` as names or objects, or `portcount`; +`simple` fittings; `component:port`, `component.name` and fresh `component.` instances; ports by +name or number, defaulting to the first; and `pipe^`, which turns the chain round. Each pipe in +a chain is a fresh run, coloured by its `colour` or `service_rating` and weighted by its `size`. +Chains are checked against pipeviz's rules: an unknown name or port, or a pipe next to a pipe, is +an error. ferrule is a little stricter: only a pipe may carry `^`, a chain turns round once, and +a pipe takes no instance. Legacy `edges` and pipeviz's `--combined` sheet are not read. The +`md` tables are written for wiring and read poorly for plumbing. + **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. @@ -94,6 +105,9 @@ engine existed. | **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. | +| **Pipes** | A run tagged with the pipe's name, coloured by service (potable blue, hot red, waste brown, vent olive) unless it has its own colour. Line weight follows bore, so a pipe reads heavier than any cable. Parts that screw straight together are joined by a short untagged line. | +| **Ports** | A port's thread and gender sit muted at the end of its row, such as `1/2" BSP · F`. | +| **U-turns** | A reversed pipe leaves both its ends eastward and turns round past them, its tag at the turn, so the parts after it lie back beside the parts before it. | | **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. | @@ -118,7 +132,7 @@ no longer be identical across machines. - [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. - [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. +- [x] **Plumbing.** Pipe runs, fittings and tanks on the same core, read from pipeviz YAML. ## Not planned diff --git a/package-lock.json b/package-lock.json index d016f04..7c90400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ferrule", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ferrule", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@resvg/resvg-js": "2.6.2", "elkjs": "0.12.0", diff --git a/package.json b/package.json index 0db6a37..4781066 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ferrule", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Wiring and plumbing diagrams from YAML", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 6340056..b6af833 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,10 @@ #!/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. +// Render WireViz looms and pipeviz plans in the Instrument style. Takes the same arguments as the +// WireViz fork's CLI where the two overlap, so a project can switch by changing the command name. // // ferrule [--prepend FILE]... [--format svg,png,md] [--output-dir DIR] LOOM.yml... // +// Each file is read as pipeviz if it has a top-level `components` or `pipes` key, else as WireViz. // 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. @@ -17,6 +18,7 @@ import { writeFonts } from "./measure.ts"; import { flatten, render } from "./render.ts"; import { buildSheet } from "./sheet.ts"; import { tables } from "./tables.ts"; +import { isPipeviz, readPipeviz } from "./pipeviz.ts"; import { readWireviz } from "./wireviz.ts"; const FORMATS = ["svg", "png", "md"] as const; @@ -58,7 +60,8 @@ async function main(argv: string[]): Promise { const out = values["output-dir"] ?? dirname(loom); mkdirSync(out, { recursive: true }); - const harness = readWireviz([...prepended, readFileSync(loom, "utf8")], name); + const plan = readFileSync(loom, "utf8"); + const harness = (isPipeviz(plan) ? readPipeviz : readWireviz)([...prepended, plan], 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`)}`); diff --git a/src/colours.ts b/src/colours.ts index 5820e7b..e394476 100644 --- a/src/colours.ts +++ b/src/colours.ts @@ -18,8 +18,9 @@ export function hex(value: unknown): string { return WIRE[value] ?? NEUTRAL; } -/** Split a WireViz colour code into stripes: "GNYE" is green with a yellow tracer. */ +/** Split a WireViz colour code into stripes: "GNYE" is green with a yellow tracer. A hex value is one stripe. */ export function stripes(code: string): string[] { + if (code.startsWith("#")) return [code]; const out: string[] = []; for (let i = 0; i < code.length; i += 2) out.push(code.slice(i, i + 2)); return out; diff --git a/src/layout/elk.ts b/src/layout/elk.ts index 6d83aa6..cf38a7f 100644 --- a/src/layout/elk.ts +++ b/src/layout/elk.ts @@ -35,6 +35,9 @@ export const ELK_OPTIONS: Record = { /** The gap between the two halves of a mated pair. */ const MATE_GAP = 48; +/** How far a returning wire's two legs stay apart where it turns round. */ +const TURN_GAP = 16; + interface Pair { id: string; a: Card; @@ -77,6 +80,16 @@ export async function layoutElk(sheet: Sheet, options: Record = height: 0, layoutOptions: { "elk.port.side": p.side === "W" ? "WEST" : "EAST" }, }); + // A wire that doubles back would drag its far end a layer further on. Instead both ends lead + // into a node of their own, east of both, where the wire turns round and its tag sits. + const returning = sheet.wires.filter((w) => w.returns); + const turns = returning.map((w) => ({ + id: `turn:${w.id}`, + width: w.tag?.w ?? TURN_GAP, + height: (w.tag?.h ?? 0) + 2 * TURN_GAP, + layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }, + ports: ["a", "b"].map((leg) => ({ id: `turn:${w.id}/${leg}`, width: 0, height: 0, layoutOptions: { "elk.port.side": "WEST" } })), + })); const graph: ElkNode = { id: "root", layoutOptions: { ...ELK_OPTIONS, ...options }, @@ -98,13 +111,20 @@ export async function layoutElk(sheet: Sheet, options: Record = ...b.ports.filter((p) => p.side === "E").map((p) => port(b, p, a.w + MATE_GAP)), ], })), + ...turns, ], - edges: sheet.wires.filter((w) => !paired.some((p) => p.mate === w.id)).map((w) => ({ + edges: [ + ...returning.flatMap((w) => [ + { id: `${w.id}/a`, sources: [`${w.from.card}/${w.from.port}`], targets: [`turn:${w.id}/a`] }, + { id: `${w.id}/b`, sources: [`${w.to.card}/${w.to.port}`], targets: [`turn:${w.id}/b`] }, + ]), + ...sheet.wires.filter((w) => !w.returns && !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}`], labels: w.tag ? [{ text: w.tag.text, width: w.tag.w, height: w.tag.h }] : [], - })), + })), + ], }; const out = await new ELK().layout(graph); @@ -121,14 +141,29 @@ export async function layoutElk(sheet: Sheet, options: Record = 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 edges = new Map((out.edges as ElkExtendedEdge[]).map((e) => [e.id, e])); + const points = (e: ElkExtendedEdge) => { const s = e.sections![0]; + return [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map(({ x, y }) => ({ x, y })); + }; + const legs = new Set(returning.flatMap((w) => [`${w.id}/a`, `${w.id}/b`])); + for (const e of edges.values()) { + if (legs.has(e.id)) continue; const label = e.labels?.[0]; routes.set(e.id, { kind: "poly", - points: [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map(({ x, y }) => ({ x, y })), + points: points(e), tag: label ? { x: label.x! + label.width! / 2, y: label.y! + label.height! / 2 } : undefined, }); } + // A returning wire runs out along one leg, down the middle of its turn, and back along the other. + for (const w of returning) { + const turn = cards.get(`turn:${w.id}`)!; + cards.delete(`turn:${w.id}`); + const a = points(edges.get(`${w.id}/a`)!), b = points(edges.get(`${w.id}/b`)!).reverse(); + const x = turn.x + (w.tag?.w ?? TURN_GAP) / 2; + const [ya, yb] = [a[a.length - 1].y, b[0].y]; + routes.set(w.id, { kind: "poly", points: [...a, { x, y: ya }, { x, y: yb }, ...b], tag: { x, y: (ya + yb) / 2 } }); + } return { engine: "elk", width: out.width!, height: out.height!, cards, routes }; } diff --git a/src/model.ts b/src/model.ts index e292bdd..91318be 100644 --- a/src/model.ts +++ b/src/model.ts @@ -6,11 +6,15 @@ export interface Pin { label: string; /** Colour codes for a pin's marking, one per stripe, as on a cable's wires. Empty when unmarked. */ colours: string[]; + /** What else the row says about the pin, such as a port's thread and gender. */ + detail?: string; } export interface Connector { id: string; template: string; + /** The name drawn on the card, when the reader gives one. Without it, the card is named from its id. */ + label?: string; type: string; subtype: string; pins: Pin[]; @@ -37,6 +41,10 @@ export interface Cable { type: string; /** Conductor size in mm², when the YAML gives one. */ gauge: number | null; + /** A pipe's bore in mm, when the YAML gives one. */ + bore?: number | null; + /** What a single run is called on its tag, ahead of its size and length. */ + label?: string; length: string; wires: CableWire[]; accent: string; @@ -54,6 +62,11 @@ export interface Link { wire: number; from: End | null; to: End | null; + /** + * The run doubles back: it leaves both ends' parts on the same side, so what follows it is laid + * out back towards where it started. Pipeviz writes this as `pipe^`. + */ + returns?: boolean; } export interface Harness { diff --git a/src/pipeviz.ts b/src/pipeviz.ts new file mode 100644 index 0000000..9715e38 --- /dev/null +++ b/src/pipeviz.ts @@ -0,0 +1,273 @@ +// Reads pipeviz YAML, the plumbing dialect, into the harness model. +// +// A component is a card and its ports are pins. Each use of a pipe in a chain is a fresh run of +// one core, drawn as a single tagged wire; two parts that mate directly get an untagged one. A +// reversed pipe, `pipe^`, turns the chain round, so what follows it is laid out heading back. +// +// Prepended files are parsed one by one and deep-merged, as pipeviz does: their `diagram`, +// `templates` and `components` sit under the plan's own. Pipes and connections come only from the +// plan. The rules come from pipeviz's validate_diagram() and the plumbing schema it implements. + +import { parse } from "yaml"; +import { hex, stripes } from "./colours.ts"; +import type { Cable, Connector, End, Harness, Link } from "./model.ts"; + +type Yaml = Record; + +/** Pipe colours by service, as wire colour codes. A pipe's own `colour` wins over its service. */ +export const SERVICE: Record = { potable: "BU", hot: "RD", waste: "BN", vent: "OL" }; + +/** True when a plan is pipeviz rather than WireViz: only pipeviz has these top-level keys. */ +export function isPipeviz(plan: string): boolean { + return /^(components|pipes):/m.test(plan); +} + +export function readPipeviz(sources: string[], name: string): Harness { + const docs: Yaml[] = sources.map((s) => parse(s, { merge: true, maxAliasCount: -1 }) ?? {}); + const plan = docs[docs.length - 1]; + const merged = (key: string): Yaml => docs.reduce((acc, d) => merge(acc, d[key] ?? {}), {}); + const diagram = merged("diagram"); + const templates = merged("templates"); + const library = merged("components"); + const pipeDefs: Yaml = plan.pipes ?? {}; + + const defs = new Map(); + for (const key of Object.keys(library)) defs.set(key, check(key, resolve(key, library, templates, []))); + + const connectors = new Map(); + const cables: Cable[] = []; + const links: Link[] = []; + const fresh = new Map(); + let runs = 0; + + function part(token: Token): Connector { + const def = defs.get(token.base)!; + let id = token.base; + if (token.instance === "") { + const n = (fresh.get(token.base) ?? 0) + 1; + fresh.set(token.base, n); + id = `__${token.base}_${n}`; + } else if (token.instance != null) id = `${token.base}.${token.instance}`; + if (!connectors.has(id)) { + const label = String(def.label) + (token.instance ? ` · ${token.instance}` : ""); + connectors.set(id, component(id, token.base, label, def)); + } + return connectors.get(id)!; + } + + function run(template: string, def: Yaml): Cable { + const id = template ? `__${template}_${++runs}` : `=${++runs}`; + const colour = colourOf(def) ?? SERVICE[def.service_rating]; + const colours = colour ? stripes(colour) : []; + const cable: Cable = { + id, + template, + type: String(def.material ?? ""), + gauge: null, + bore: bore(def.size), + label: def.label == null ? "" : String(def.label), + length: def.length == null ? "" : String(def.length), + wires: [{ index: 1, label: "", code: "", colours }], + accent: hex(colour), + notes: lines(def.description), + }; + cables.push(cable); + return cable; + } + + for (const [i, chain] of ((plan.connections ?? []) as unknown[][]).entries()) { + const where = `connections[${i}]`; + try { + if (!Array.isArray(chain) || chain.length < 2) throw new Error("a chain needs at least two tokens"); + const tokens = chain.map((t) => token(String(t), defs, pipeDefs)); + tokens.forEach((t, j) => { + if (t.pipe && tokens[j + 1]?.pipe) throw new Error(`${t.base} → ${tokens[j + 1].base}: pipe to pipe is not a connection`); + }); + const turns = tokens.filter((t) => t.reversed); + if (turns.length > 1) throw new Error("a chain can turn round only once"); + if (turns.length && !turns[0].pipe) throw new Error(`${turns[0].base}^: only a pipe can turn a chain round`); + + const steps = tokens.map((t) => ({ t, end: t.pipe ? null : end(part(t), t) })); + const link = (pipe: Token | null, from: End | null, to: End | null, returns = false) => { + const cable = pipe ? run(pipe.base, pipeDefs[pipe.base] ?? {}) : run("", {}); + links.push({ cable: cable.id, wire: 1, from, to, ...(returns && { returns }) }); + }; + // Each pipe joins its neighbours; two parts side by side mate directly. + const walk = (seq: typeof steps) => + seq.forEach((s, j) => { + const next = seq[j + 1]; + if (s.t.pipe) link(s.t, seq[j - 1]?.end ?? null, next?.end ?? null); + else if (next && !next.t.pipe) link(null, s.end, next.end); + }); + // After a turn the chain is read backwards, so it runs towards the turn and meets it there. + const turn = tokens.findIndex((t) => t.reversed); + if (turn < 0) walk(steps); + else { + walk(steps.slice(0, turn)); + link(tokens[turn], steps[turn - 1]?.end ?? null, steps[turn + 1]?.end ?? null, true); + walk(steps.slice(turn + 1).reverse()); + } + } catch (err) { + throw new Error(`${where} ${JSON.stringify(chain)}: ${(err as Error).message}`); + } + } + + // A fresh instance carries a number only when its part is used fresh more than once. + for (const c of connectors.values()) { + const m = /^__.+_(\d+)$/.exec(c.id); + if (m && (fresh.get(c.template) ?? 0) > 1) c.label += ` · ${m[1]}`; + } + + return { + title: diagram.title == null ? titleCase(name) : String(diagram.title), + connectors: [...connectors.values()], + cables, + links, + mates: [], + }; +} + +interface Token { + base: string; + /** `null` for the part itself, `""` for a fresh instance, else the instance's name. */ + instance: string | null; + port: string | null; + reversed: boolean; + pipe: boolean; +} + +function token(text: string, defs: Map, pipes: Yaml): Token { + const colon = text.indexOf(":"); + let head = colon < 0 ? text : text.slice(0, colon); + const port = colon < 0 ? null : text.slice(colon + 1); + if (port != null && (port.startsWith("[") || port.includes(",") || /^\d+-\d+$/.test(port))) { + throw new Error(`${text}: a token names at most one port`); + } + const reversed = head.endsWith("^"); + if (reversed) head = head.slice(0, -1); + const dot = head.indexOf("."); + const base = dot < 0 ? head : head.slice(0, dot); + const instance = dot < 0 ? null : head.slice(dot + 1); + if (defs.has(base)) return { base, instance, port, reversed, pipe: false }; + if (base in pipes) { + if (instance != null) throw new Error(`${text}: every use of a pipe is already its own run, so it takes no instance`); + return { base, instance: null, port, reversed, pipe: true }; + } + throw new Error(`${text}: no component or pipe is defined as ${base}`); +} + +/** The pin a token lands on: its port by name, then by number, or the first when it names none. */ +function end(c: Connector, t: Token): End { + if (c.simple) { + if (t.port != null) throw new Error(`${c.id}: a simple component has no ports to name`); + return { connector: c.id, pin: "1" }; + } + if (t.port == null) return { connector: c.id, pin: "1" }; + const byName = c.pins.find((p) => p.label === t.port); + if (byName) return { connector: c.id, pin: byName.num }; + if (/^\d+$/.test(t.port) && Number(t.port) >= 1 && Number(t.port) <= c.pins.length) return { connector: c.id, pin: t.port }; + throw new Error(`${t.base}: no port ${t.port}`); +} + +function component(id: string, template: string, label: string, def: Yaml): Connector { + const ports = portList(def); + return { + id, + template, + label, + type: String(def.manufacturer ?? ""), + subtype: String(def.model ?? ""), + pins: def.simple + ? [{ num: "1", label: "", colours: [] }] + : ports.map((p, i) => { + const detail = [p.connection_size, p.gender].filter((v) => v != null && v !== "").join(" · "); + return { num: String(i + 1), label: String(p.name), colours: [], ...(detail && { detail }) }; + }), + simple: !!def.simple, + loops: [], + accent: hex(colourOf(def)), + notes: lines(def.description), + }; +} + +/** Ports as objects, whether written as names or as objects; a bare `portcount` numbers them. */ +function portList(def: Yaml): Yaml[] { + if (Array.isArray(def.ports)) return def.ports.map((p: unknown) => (p && typeof p === "object" ? p : { name: String(p) })); + return Array.from({ length: Number(def.portcount ?? 0) }, (_, i) => ({ name: String(i + 1) })); +} + +function check(key: string, def: Yaml): Yaml { + if (!def.label) throw new Error(`component ${key} needs a label`); + const has = "ports" in def || "portcount" in def; + if (def.simple && has) throw new Error(`component ${key} is simple, so it can't have ports or portcount`); + if (!def.simple && !has) throw new Error(`component ${key} needs ports or portcount`); + if (Array.isArray(def.ports) && "portcount" in def && Number(def.portcount) !== def.ports.length) { + throw new Error(`component ${key}: portcount ${def.portcount} doesn't match its ${def.ports.length} ports`); + } + return def; +} + +/** A component with its `ref` and `template` chains applied, its own keys on top. */ +function resolve(key: string, library: Yaml, templates: Yaml, stack: string[]): Yaml { + if (stack.includes(key)) throw new Error(`component cycle: ${[...stack, key].join(" → ")}`); + let def: Yaml = { ...(library[key] ?? {}) }; + const ref = def.ref; + delete def.ref; + if (ref != null) { + if (!(ref in library)) throw new Error(`${key}: no component ${ref} to ref`); + def = merge(resolve(ref, library, templates, [...stack, key]), def); + } + const template = def.template; + delete def.template; + if (template != null) def = merge(templateChain(String(template), templates, []), def); + return def; +} + +function templateChain(name: string, templates: Yaml, stack: string[]): Yaml { + if (stack.includes(name)) throw new Error(`template cycle: ${[...stack, name].join(" → ")}`); + if (!(name in templates)) throw new Error(`no template ${name}`); + const t: Yaml = { ...templates[name] }; + const parent = t.template; + delete t.template; + return parent == null ? t : merge(templateChain(String(parent), templates, [...stack, name]), t); +} + +function merge(base: Yaml, over: Yaml): Yaml { + const out: Yaml = { ...base }; + for (const [k, v] of Object.entries(over)) { + out[k] = isMap(out[k]) && isMap(v) ? merge(out[k], v) : v; + } + return out; +} + +const isMap = (v: unknown): v is Yaml => !!v && typeof v === "object" && !Array.isArray(v); + +/** A pipe's bore in mm, from `25mm`, `3/4"`, `1-1/2"` or `1.5in`. */ +export function bore(size: unknown): number | null { + if (size == null) return null; + const s = String(size).trim(); + const mm = /^([\d.]+)\s*mm$/i.exec(s); + if (mm) return Number(mm[1]); + const inch = /^(?:(\d+)[- ])?(?:(\d+)\/(\d+)|([\d.]+))\s*(?:"|in|inch|inches)$/i.exec(s); + if (!inch) return null; + const [, whole, num, den, dec] = inch; + const n = (whole ? Number(whole) : 0) + (dec ? Number(dec) : Number(num) / Number(den)); + return Math.round(n * 25.4 * 100) / 100; +} + +/** A part's or pipe's own colour, as hex or an upper-case colour code, when it has one. */ +function colourOf(def: Yaml): string | undefined { + const c = def.colour ?? def.color; + if (c == null || c === "") return undefined; + const s = String(c); + return s.startsWith("#") ? s : s.toUpperCase(); +} + +function lines(text: unknown): string[] { + if (typeof text !== "string") return []; + return text.split("\n").map((l) => l.trim()).filter(Boolean); +} + +function titleCase(name: string): string { + return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/src/render.ts b/src/render.ts index 958a22c..21135df 100644 --- a/src/render.ts +++ b/src/render.ts @@ -133,9 +133,12 @@ function drawCard(g: Glyphs, c: Card, p: Pt): string[] { const dash = c.kind === "cable" ? ' stroke-dasharray="5 3"' : ""; 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)); + c.titleLines.forEach((line, j) => { + o.push(text(g, `x="${n(p.x + 28)}" y="${n(p.y + 21 + j * S.titleLh)}" font-size="${S.titleSize}"`, line, 600)); + }); + const subTop = p.y + 39 + (c.titleLines.length - 1) * S.titleLh; 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)); + o.push(text(g, `class="muted" x="${n(p.x + 12)}" y="${n(subTop + 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. @@ -151,6 +154,7 @@ function drawCard(g: Glyphs, c: Card, p: Pt): string[] { 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 (r.detail) o.push(text(g, `class="faint" x="${n(p.x + c.w - 12)}" y="${n(base)}" text-anchor="end" font-size="${S.rowSize - 1}"`, r.detail)); }); if (c.notes.length) { const ny = p.y + c.rowTop + c.rows.length * c.rowH; @@ -205,8 +209,12 @@ 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. */ +/** + * A core's stroke, from a colour code or a hex value. Black and white follow the theme, so neither + * matches the ground it's drawn on. + */ function coreColour(code: string): string { + if (code.startsWith("#")) return code; if (code === "BK") return "var(--core-bk)"; if (code === "WH") return "var(--core-wh)"; return WIRE[code] ?? WIRE.GY; diff --git a/src/sheet.ts b/src/sheet.ts index bdedc91..ec214dc 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -14,6 +14,7 @@ export const STYLE = { minW: 150, maxW: 300, titleSize: 13, + titleLh: 16, subSize: 10.5, rowSize: 11.5, noteSize: 10, @@ -32,6 +33,8 @@ export interface Row { label: string; /** Colour codes for the pin's marking, one per stripe. */ colours?: string[]; + /** Muted text at the row's end, such as a port's thread and gender. */ + detail?: string; /** Set on a row that stands in for a run of unused pins. */ fold?: string; } @@ -49,6 +52,8 @@ export interface Card { id: string; kind: "connector" | "simple" | "cable"; title: string; + /** The title, wrapped to the card. Only a title wider than the widest card takes two lines. */ + titleLines: string[]; /** Type and subtype, wrapped to the card. */ subLines: string[]; accent: string; @@ -73,6 +78,8 @@ export interface Wire { id: string; /** A mate is two connectors plugged together, drawn between their title bars. */ kind?: "mate"; + /** The wire doubles back, leaving both ends on their east side. */ + returns?: boolean; from: { card: string; port: string }; to: { card: string; port: string }; colours: string[]; @@ -86,9 +93,12 @@ export interface Sheet { wires: Wire[]; } -/** Line weight for a conductor size in mm², so a 50 mm² run looks like one. */ -export function weight(gauge: number | null): number { - const w = gauge == null ? 0 : 2 + 0.75 * Math.sqrt(gauge); +/** + * Line weight for a conductor size in mm², so a 50 mm² run looks like one, or for a pipe's bore in + * mm, which grows faster so a pipe reads heavier than any cable. + */ +export function weight(gauge: number | null, bore: number | null = null): number { + const w = bore != null ? 2 + 0.25 * bore : gauge == null ? 0 : 2 + 0.75 * Math.sqrt(gauge); return Math.round(Math.max(3, w) * 10) / 10; } @@ -112,12 +122,13 @@ export function buildSheet(h: Harness): Sheet { 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) }; + const base = { colours: core.colours.length ? core.colours : ["GY"], weight: weight(c.gauge, c.bore ?? null) }; if (isRun(c) && l.from && l.to) { wires.push({ id: `w${i}`, from: touch(l.from.connector, l.from.pin, "E"), - to: touch(l.to.connector, l.to.pin, "W"), + to: touch(l.to.connector, l.to.pin, l.returns ? "E" : "W"), + ...(l.returns && { returns: true }), ...base, tag: tag(c), }); @@ -148,7 +159,7 @@ export function buildSheet(h: Harness): Sheet { } function tag(c: Cable): Tag | undefined { - const text = cableSpec(c); + const text = [c.label ?? "", cableSpec(c)].filter(Boolean).join(" · "); if (!text) return undefined; return { text, w: tagWidth(text), h: STYLE.tagH }; } @@ -180,12 +191,13 @@ export function displayName(id: string, template: string, repeated: Set) function connectorCard(c: Connector, used: Map>, inUse: Set, repeated: Set): Card { const sub = [c.type, c.subtype].filter(Boolean).join(" · "); - const title = displayName(c.id, c.template, repeated); + const title = c.label || 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, [], []); + card.titleLines = wrapTitle(title, w - 40); card.subLines = wrap([sub], w - 24, STYLE.subSize); - card.h = STYLE.simpleH + extraSubLines(card) * STYLE.subLh; + card.h = STYLE.simpleH + extraHead(card); card.ports = ports(used, card, () => card.h / 2); return card; } @@ -201,6 +213,7 @@ function connectorCard(c: Connector, used: Map>, inUse: Set>, inUse: Set>, repeated: Set): Card { - const title = displayName(c.id, c.template, repeated); + const title = c.label || displayName(c.id, c.template, repeated); const sub = [c.type, cableSpec(c)].filter(Boolean).join(" · "); const rows = c.wires.map((w) => ({ key: String(w.index), num: String(w.index), label: w.label })); return finish(base(c.id, "cable", title, sub, c.accent, 0, rows, c.notes), used); } function base(id: string, kind: Card["kind"], title: string, sub: string, accent: string, w: number, rows: Row[], notes: string[]): Card { - return { id, kind, title, subLines: sub ? [sub] : [], accent, w, h: 0, rowTop: STYLE.headH, rowH: STYLE.rowH, rows, loops: [], notes, ports: [] }; + return { id, kind, title, titleLines: [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); +/** How much a wrapped title and subtitle deepen the header: one line of each fits as standard. */ +function extraHead(card: Card): number { + return (card.titleLines.length - 1) * STYLE.titleLh + Math.max(0, card.subLines.length - 1) * STYLE.subLh; } function finish(card: Card, used: Map>): Card { @@ -236,12 +249,13 @@ function finish(card: Card, used: Map>): Card { card.w = width([ textWidth(card.title, s.titleSize, 600) + 28 + 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)), + ...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 + (r.detail ? textWidth(r.detail, s.rowSize - 1) + 16 : 0))), ...notes.map((n) => textWidth(n, s.noteSize) + 2 * s.pad), ]); + card.titleLines = wrapTitle(card.title, card.w - 28 - s.pad); 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.rowTop = s.headH + extraHead(card); 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; @@ -279,13 +293,18 @@ function paragraphs(lines: string[]): string[] { return out; } -function wrap(texts: string[], max: number, size: number): string[] { +/** A title wrapped to `max`, keeping an instance's ` · 2` with the word before it. */ +function wrapTitle(title: string, max: number): string[] { + return wrap([title.replace(/ · /g, "\u00a0·\u00a0")], max, STYLE.titleSize, 600).map((l) => l.replace(/\u00a0/g, " ")); +} + +function wrap(texts: string[], max: number, size: number, fontWeight = 400): string[] { const out: string[] = []; for (const text of texts) { let line = ""; - for (const word of text.split(/\s+/)) { + for (const word of text.split(/[ \t\n\r]+/)) { const next = line ? `${line} ${word}` : word; - if (line && textWidth(next, size) > max) { + if (line && textWidth(next, size, fontWeight) > max) { out.push(line); line = word; } else line = next; diff --git a/test/metrics.test.ts b/test/metrics.test.ts index e04b046..2aba170 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, subLines: [], accent: "#888888", w, h, rowTop: 0, rowH: 24, rows: [], loops: [], notes: [], ports: [], + id, kind: "connector", title: id, titleLines: [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/pipeviz.test.ts b/test/pipeviz.test.ts new file mode 100644 index 0000000..1c91e20 --- /dev/null +++ b/test/pipeviz.test.ts @@ -0,0 +1,151 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { hex } from "../src/colours.ts"; +import { readPipeviz } from "../src/pipeviz.ts"; +import type { Harness } from "../src/model.ts"; + +const shared = ` +templates: + fitting: + color: "#64748b" + tank: + color: "#2563eb" +components: + tee: + template: fitting + label: 25MM BARB TEE + ports: + - name: A + connection_size: 25MM BARB + gender: M + - name: B + - name: C + adapter: + template: fitting + label: 25MM BARB ADAPTER + simple: true +`; + +const plan = ` +components: + tank: + template: tank + label: 95L FRESH WATER TANK + manufacturer: Camec + description: > + 95L slimline tank. + ports: [HIGH, LOW] + manifold: + label: MANIFOLD + portcount: 3 +pipes: + hose: + label: 25MM FILL HOSE + size: 25mm + service_rating: potable + loop: + label: UNDERFLOOR LOOP + size: 3/4" + color: "#f97316" +connections: + - - tee:A + - hose + - adapter. + - tank.a:HIGH + - - tee:B + - hose + - tank.b + - - manifold:2 + - tee:C +`; + +const read = (loom: string, prepend = [shared]): Harness => readPipeviz([...prepend, loom], "fresh_tanks"); +const h = read(plan); +const conn = (id: string) => h.connectors.find((c) => c.id === id)!; + +test("titles a sheet from its file name unless the diagram names it", () => { + assert.equal(h.title, "Fresh Tanks"); + assert.equal(read(`diagram:\n title: Fill\n${plan}`).title, "Fill"); +}); + +test("merges prepended templates and components under the plan's own", () => { + assert.equal(conn("tee").label, "25MM BARB TEE"); + assert.equal(conn("tee").accent, "#64748b"); + assert.equal(conn("tank.a").accent, "#2563eb"); +}); + +test("reads ports as pins, numbered from 1, with their size and gender", () => { + assert.deepEqual(conn("tee").pins, [ + { num: "1", label: "A", colours: [], detail: "25MM BARB · M" }, + { num: "2", label: "B", colours: [] }, + { num: "3", label: "C", colours: [] }, + ]); + assert.deepEqual(conn("manifold").pins.map((p) => p.label), ["1", "2", "3"]); +}); + +test("carries manufacturer and description onto the card", () => { + assert.equal(conn("tank.a").type, "Camec"); + assert.deepEqual(conn("tank.a").notes, ["95L slimline tank."]); +}); + +test("names instances on the card: a named one by its name, fresh ones only by number when repeated", () => { + assert.equal(conn("tank.a").label, "95L FRESH WATER TANK · a"); + const fresh = h.connectors.filter((c) => c.template === "adapter"); + assert.deepEqual(fresh.map((c) => [c.id, c.label, c.simple]), [["__adapter_1", "25MM BARB ADAPTER", true]]); + const twice = read(`${plan} - - adapter.\n - tee:C\n`); + assert.deepEqual(twice.connectors.filter((c) => c.template === "adapter").map((c) => c.label), [ + "25MM BARB ADAPTER · 1", + "25MM BARB ADAPTER · 2", + ]); +}); + +test("makes each pipe usage its own run, coloured by service and weighted by bore", () => { + const hoses = h.cables.filter((c) => c.template === "hose"); + assert.equal(hoses.length, 2); + assert.deepEqual( + { label: hoses[0].label, bore: hoses[0].bore, colours: hoses[0].wires[0].colours }, + { label: "25MM FILL HOSE", bore: 25, colours: ["BU"] }, + ); + const loop = read(`${plan} - - tank.a:LOW\n - loop\n - tank.b:LOW\n`).cables.find((c) => c.template === "loop")!; + assert.deepEqual([loop.bore, loop.wires[0].colours], [19.05, ["#f97316"]]); + const lower = read(plan.replace('color: "#f97316"', "color: og") + " - - tank.a:LOW\n - loop\n - tank.b:LOW\n").cables.find((c) => c.template === "loop")!; + assert.deepEqual([lower.wires[0].colours, lower.accent], [["OG"], hex("OG")]); +}); + +test("links each hop, defaulting an omitted port to the first and joining parts that mate directly", () => { + const ends = h.links.map((l) => [l.from && `${l.from.connector}:${l.from.pin}`, l.to && `${l.to.connector}:${l.to.pin}`]); + assert.deepEqual(ends, [ + ["tee:1", "__adapter_1:1"], + ["__adapter_1:1", "tank.a:1"], + ["tee:2", "tank.b:1"], + ["manifold:2", "tee:3"], + ]); + const direct = h.cables.find((c) => c.id === h.links[1].cable)!; + assert.deepEqual([direct.label, direct.bore, direct.wires[0].colours], ["", null, []]); +}); + +test("turns a chain round at a reversed pipe: the rest of it runs back towards the start", () => { + const u = read(`${plan} - - tank.a:LOW\n - adapter.\n - loop^\n - adapter.\n - tank.b:LOW\n`); + const tail = u.links.slice(-3).map((l) => [`${l.from!.connector}:${l.from!.pin}`, `${l.to!.connector}:${l.to!.pin}`, !!l.returns]); + assert.deepEqual(tail, [ + ["tank.a:2", "__adapter_2:1", false], + ["__adapter_2:1", "__adapter_3:1", true], + ["tank.b:2", "__adapter_3:1", false], + ]); +}); + +test("rejects what pipeviz rejects", () => { + const bad = (connections: string, extra = "") => () => read(`${extra}${plan.replace(/connections:[\s\S]*/, `connections:\n${connections}`)}`); + assert.throws(bad(" - [tee:A]\n"), /at least two/); + assert.throws(bad(" - [tee:A, hose, hose, tank]\n"), /pipe to pipe/); + assert.throws(bad(" - [tee:A, nothing]\n"), /nothing/); + assert.throws(bad(" - [tee:D, tank]\n"), /no port D/); + assert.throws(bad(" - [manifold:4, tank]\n"), /no port 4/); + assert.throws(bad(" - [adapter.:A, tank]\n"), /simple/); + assert.throws(bad(" - ['tee:[A,B]', tank]\n"), /one port/); + assert.throws(bad(" - [tee:A, loop^, tank.a, loop^, tank.b]\n"), /once/); + assert.throws(bad(" - [tee:A, hose.x, tank]\n"), /no instance/); + assert.throws(() => read(`components:\n x:\n ports: [A]\nconnections:\n - [x, x.b]\n`, []), /label/); + assert.throws(() => read(`components:\n x:\n label: X\nconnections:\n - [x, x.b]\n`, []), /ports or portcount/); + assert.throws(() => read(`components:\n x:\n label: X\n ports: [A]\n portcount: 2\nconnections:\n - [x, x.b]\n`, []), /portcount/); +}); diff --git a/test/render.test.ts b/test/render.test.ts index b792532..c4b2f3e 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -3,6 +3,7 @@ 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 { readPipeviz } from "../src/pipeviz.ts"; import { readWireviz } from "../src/wireviz.ts"; const loom = ` @@ -62,3 +63,37 @@ test("a mated pair is laid out side by side, joined by a short mate", async () = assert.equal(p.y, s.y); assert.ok(s.x > p.x + pw && s.x - (p.x + pw) < 80); }); + +const crosslink = ` +components: + tank: + label: TANK + ports: [HIGH, LOW] + adapter: + label: ADAPTER + simple: true +pipes: + hose: + label: 40MM HOSE + size: 40mm + color: "#f97316" +connections: + - [tank.a:LOW, adapter., hose^, adapter., tank.b:LOW] +`; + +test("a reversed pipe turns round east of both its ends, with its tag at the turn", async () => { + const sheet = buildSheet(readPipeviz([crosslink], "crosslink")); + const place = await layoutElk(sheet); + const hose = sheet.wires.find((w) => w.returns)!; + const route = place.routes.get(hose.id)!; + const [first, last] = [route.points[0], route.points.at(-1)!]; + const rightEdge = (id: string) => place.cards.get(id)!.x + sheet.cards.find((c) => c.id === id)!.w; + assert.equal(first.x, rightEdge(hose.from.card)); + assert.equal(last.x, rightEdge(hose.to.card)); + // Both adapters sit in the same layer, beside their tanks, rather than one a layer further on. + assert.equal(place.cards.get(hose.from.card)!.x, place.cards.get(hose.to.card)!.x); + assert.ok(route.tag!.x > Math.max(first.x, last.x)); + const out = await render(sheet, place); + assert.match(out, /stroke:#f97316/); + assert.match(out, />40MM HOSE { assert.equal(s.cards.find((c) => c.id === "__LAMP_1")!.title, "LAMP"); assert.equal(s.cards.find((c) => c.id === "__RUN_1"), undefined); }); + +test("a pipe's line weight grows with its bore", () => { + assert.ok(weight(null, 40) > weight(null, 16)); + assert.ok(weight(null, 16) > weight(null)); +}); + +test("plumbing parts are titled by their label, show port detail, and pipes are tagged by name", () => { + const plumb: Harness = { + title: "p", + connectors: [ + { ...conn("TANK", 2), label: "95L FRESH WATER TANK · a", pins: [{ num: "1", label: "HIGH", colours: [], detail: '1-1/2" BSP · F' }, { num: "2", label: "LOW", colours: [] }] }, + { ...conn("__adapter_1", 1, true), template: "adapter", label: "ADAPTER" }, + { ...conn("__adapter_2", 1, true), template: "adapter", label: "ADAPTER" }, + ], + cables: [ + { id: "__hose_1", template: "hose", type: "PVC", gauge: null, bore: 25, label: "25MM FILL HOSE", length: "", accent: "#888888", notes: [], + wires: [{ index: 1, label: "", code: "", colours: ["#f97316"] }] }, + { id: "=2", template: "", type: "", gauge: null, bore: null, label: "", length: "", accent: "#888888", notes: [], + wires: [{ index: 1, label: "", code: "", colours: [] }] }, + ], + links: [ + { cable: "__hose_1", wire: 1, from: { connector: "__adapter_1", pin: "1" }, to: { connector: "__adapter_2", pin: "1" }, returns: true }, + { cable: "=2", wire: 1, from: { connector: "TANK", pin: "1" }, to: { connector: "__adapter_1", pin: "1" } }, + ], + mates: [], + }; + const s = buildSheet(plumb); + const tank = s.cards.find((c) => c.id === "TANK")!; + assert.equal(tank.title, "95L FRESH WATER TANK · a"); + assert.equal(tank.rows[0].detail, '1-1/2" BSP · F'); + assert.ok(tank.w >= buildSheet({ ...plumb, connectors: [{ ...plumb.connectors[0], pins: pins(2) }] }).cards[0].w); + const [hose, direct] = s.wires; + assert.equal(hose.tag?.text, "25MM FILL HOSE"); + assert.equal(hose.weight, weight(null, 25)); + assert.deepEqual([hose.from.port, hose.to.port, hose.returns], ["1:E", "1:E", true]); + assert.equal(direct.tag, undefined); +}); + +test("a title too long for the widest card wraps and deepens the header", () => { + const long = 'DWV ADAPTER 1-1/2" BSP FEMALE → 40MM BARB · 2'; + const s = buildSheet({ ...harness, connectors: [{ ...conn("ISO", 1, true), label: long }, { ...conn("A", 12), label: long }] }); + for (const c of s.cards.filter((k) => k.kind !== "cable")) { + assert.ok(c.titleLines.length > 1, c.id); + assert.equal(c.titleLines.join(" "), long); + assert.ok(c.titleLines.at(-1)!.endsWith("BARB · 2"), c.titleLines.join(" / ")); + } + const plain = buildSheet({ ...harness, connectors: [conn("ISO", 1, true), conn("A", 12)] }); + assert.ok(s.cards[0].h > plain.cards[0].h); + assert.ok(s.cards[1].rowTop > plain.cards[1].rowTop); + assert.deepEqual(plain.cards[1].titleLines, ["A"]); +}); diff --git a/test/wireviz.test.ts b/test/wireviz.test.ts index 2cb0297..a4a0c38 100644 --- a/test/wireviz.test.ts +++ b/test/wireviz.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { stripes } from "../src/colours.ts"; import { readWireviz } from "../src/wireviz.ts"; const shared = ` @@ -115,6 +116,10 @@ test("two-colour codes split into stripes", () => { assert.deepEqual(twin.wires.map((w) => w.colours), [["RD"], ["GN", "YE"]]); }); +test("a hex colour is one stripe, not split like a code", () => { + assert.deepEqual(stripes("#d2b48c"), ["#d2b48c"]); +}); + test("an unknown pin is an error, not a silent gap", () => { const bad = loom.replace("SHUNT: [LOAD-]", "SHUNT: [NOPE]"); assert.throws(() => readWireviz([shared, bad], "x"), /SHUNT.*NOPE/);