Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <project> --python <python with WireViz>` reads every
loom with ferrule and with WireViz and fails if they disagree on any connector, connection, mate
or loop.
Expand Down Expand Up @@ -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. |

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 6 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <loom>.svg, which follows the viewer's light or dark mode, and <loom>.png, rendered dark.
// `md` adds <loom>.md, a cut list and a pinout for each connector, and is only written on request.

Expand All @@ -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;
Expand Down Expand Up @@ -58,7 +60,8 @@ async function main(argv: string[]): Promise<number> {
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`)}`);
Expand Down
3 changes: 2 additions & 1 deletion src/colours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 39 additions & 4 deletions src/layout/elk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const ELK_OPTIONS: Record<string, string> = {
/** 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;
Expand Down Expand Up @@ -77,6 +80,16 @@ export async function layoutElk(sheet: Sheet, options: Record<string, string> =
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 },
Expand All @@ -98,13 +111,20 @@ export async function layoutElk(sheet: Sheet, options: Record<string, string> =
...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);
Expand All @@ -121,14 +141,29 @@ export async function layoutElk(sheet: Sheet, options: Record<string, string> =
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 };
}
13 changes: 13 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Loading
Loading