Reconstruct electrical connectivity (a netlist) from vector wiring-diagram PDFs (WD schematics).
A wiring diagram drawn as vector graphics stores its real connectivity as line
segments — information that is lost when the page is rasterized to PNG and that
plain PDF text extraction ignores entirely. wd-netlist reads the vector
geometry and the positioned text, then groups connected segments into nets and
attaches the nearest labels (signal names, wire colors, connector/part numbers).
Status: working prototype. Junction semantics + a decoration filter (both on by default) keep crossings and component illustrations from fusing unrelated signals (largest net on the reference WD: 921 → 176 nodes). A few hard over-merges remain where one drawn rail genuinely links several connectors. See Roadmap.
- Extract line segments and words (with coordinates) from a page — PyMuPDF.
- Snap segment endpoints to a tolerance grid and union-find them into connected groups (nets).
- Attach each text label to the nearest net via a spatial grid index.
- Report stats and per-net labels (text or JSON).
uv syncCLI:
uv run wd-netlist path/to/diagram.pdf
uv run wd-netlist path/to/diagram.pdf --json
uv run wd-netlist path/to/diagram.pdf --max-width 1.0 --min-labels 3 --top 30
# export to standard formats (wire list, SPICE, KiCad, EDIF, KBL/VEC, graphs)
uv run wd-netlist path/to/diagram.pdf --std all --std-out auto
# component neighborhood subgraph (connectors linked within N hops)
uv run wd-netlist path/to/diagram.pdf --subgraph X16 --hops 2 --subgraph-out autoBy default the CLI runs the high-fidelity reconstruction: junction semantics
(crossings without a dot don't merge) + the decoration filter + a sub-pixel-noise
min-seg-len. Pass --legacy to restore the old naive union-everything pass
(it over-merges dense areas; only needed to reproduce pre-0.2 output).
Key options:
--legacyrestore the old naive reconstruction (no junction semantics / filter)--snap-tolendpoint merge tolerance (pt, default 1.5)--label-tolmax label↔net distance (pt, default 10); labels attach to the nearest wire body, not just the nearest endpoint--min-seg-lendiscard micro-segments (pt, default 1.0; 0.3 under--legacy)--decoration-densityflag dense illustration nets and drop them (default 0.012)--max-widthkeep only thin strokes (filter out thick outlines)--include-rectstreat rectangle edges as wires (off by default)--stdexport to standard formats (wirelist,graphml,jsongraph,dot,spice,kicad,edif,kbl,vec, orall); use with--std-out autofor<stem>.out/standard/netlist.*--subgraph REFexport the component neighborhood around connectorREF--hops Nmax component-to-component hops for--subgraph(default 1)--subgraph-out PATHoutput directory;auto→<stem>.out/subgraph/<REF>/
Library:
from wd_netlist import extract_page, build_netlist
segments, words = extract_page("diagram.pdf", page_number=0)
netlist = build_netlist(segments, words, snap_tol=1.5, label_tol=10.0)
print(netlist.stats())
for net in sorted(netlist.labeled_nets(), key=lambda n: len(n.labels), reverse=True)[:10]:
print(net.id, net.node_count, net.labels)uv run pytestTests use synthetic geometry only — no PDF, no network access.
The full-fidelity scene (extract_scene) captures every vector primitive and
text span with style, enough to redraw the page. scene_to_doc / render_scene
rebuild an image from it, and --roundtrip reports the pixel diff between the
original and the regenerated page (plus extraction idempotence).
uv run wd-netlist diagram.pdf --regen auto # regenerated image
uv run wd-netlist diagram.pdf --regen out.png --render-dpi 200 # at a chosen path/dpi
uv run wd-netlist diagram.pdf --regen crop.png --clip x0,y0,x1,y1 # just a region
uv run wd-netlist diagram.pdf --dump-scene auto --regen-scene auto # scene.json + image
uv run wd-netlist diagram.pdf --roundtrip # fidelity report--regen is the one-shot command: it extracts the styled scene from the PDF
(vectors, text, raster images) and redraws it to an image — no intermediate scene
file needed. auto writes <stem>.work/regen.png.
Two things are essential for the regenerated image to match the original:
- Path grouping — a filled shape is stored as several line items plus a fill;
the elements of one original drawing share a
pathid and are stroked/filled once, so the enclosed area fills (junction dots, ground triangles, solid components). Finishing each line on its own loses every fill. - Text direction — these schematics are on 90°-rotated pages, so the text runs
vertically in unrotated space (
span.dir). Each span is drawn with the matchingrotateangle; writing it horizontally made all text come out sideways. - Raster images — component photos/illustrations (the NEO3 HMI display, the RTU
unit) are embedded image XObjects, not vector paths. The scene captures each as a
SceneImage(bytes + placement bbox) and re-inserts it, so they survive the round-trip (BBCC has 131 such placements; ZW has none).
Together these cut the reference round-trip image diff roughly in half (ZW 5.5%→2.7%, BBCC 11%→4.5%). The residual is mostly font substitution (PyMuPDF base-14 Helvetica for the embedded fonts).
Given a connector/component designator (e.g. X16), extract every other
component reachable within N hops along labeled nets (1 hop = direct
neighbors; 2 hops = neighbors of neighbors, and so on). Writes data files plus a
PNG graph image under <stem>.out/subgraph/<REF>/:
uv run wd-netlist diagram.pdf --subgraph X16 --hops 2 --subgraph-out autoOutputs: netlist.graphml, netlist.jsongraph.json, netlist.csv,
netlist.dot (Graphviz), and <REF>.png (component nodes at diagram positions,
edges = cables). Library:
from wd_netlist import build_netlist, extract_page, neighborhood, layout_positions
from wd_netlist.standard import build_standard_netlist
from wd_netlist.extract import render_subgraph
segments, words = extract_page("diagram.pdf")
netlist = build_netlist(segments, words)
std = build_standard_netlist(netlist, words, segments=segments)
sub, hop_dist = neighborhood(std, "X16", hops=2)
render_subgraph(layout_positions(sub), sub, "x16-neighborhood.png",
hop_by_ref=hop_dist, start_ref="X16")Turning "connected groups" into a correct terminal→wire→terminal netlist. Items
1–5 are implemented and on by default (1, 2) or available via --export /
--vision-bundle (3–5); see the netlist-algorithm skill for details.
Wire vs decoration(done) — dense illustration nets are flagged by node density and dropped (--decoration-density).Junction semantics(done) — two lines crossing without a junction dot are not connected; dots/T-joints/continuations are. On by default.Terminal/pin model(done) — pins are attributed to connectors and labels attach to the nearest wire body (--export).Netlist export(done) — normalized, LLM-friendly JSON with classified labels, terminals and connections (--export).Hybrid vision(first cut) — per-connector image crops + index for an LLM to resolve symbols/annotations (--vision-bundle).
Split connector designators (done) — the text layer often emits a
connector label as two tokens (CN + 8, RL + 1); export.merge_split_designators
fuses an axis-aligned prefix+number pair back into one CN8 designator before
attribution, so its pins attribute correctly and emit named connections. On the
reference ZW WD this took fully-attributed connections from 0 to 15 (every prior
connection had an unattributed ?.N terminal).
Component type (done) — each connector in the --vision-bundle index gets a
type (relay/fuse/connector) from export.infer_component_type: the
DIN-72552 relay coil pins (85 & 86) co-occurring is a robust relay signature, and
an F-prefixed designator is a fuse. The wider per-connector crop (it now frames
the surrounding signal/colour labels, not just the pin row) lets a vision model
refine the rest.
Semantic netlist (done) — the --vision-bundle also writes prompts.json
(one pin→signal question per connector). A vision model answers them into a
resolution map {connector: {pin: signal}}; feeding that back with
--resolve map.json [--export out.json] (export.apply_resolution) produces the
semantic netlist: edges carrying each terminal's resolved signal and a derived
net name. When both ends of a geometric edge resolve to the same signal (e.g.
CN36.12[VCC+30] — CN9.5[VCC+30]) the connection is cross-checked / corroborated;
when they differ it flags a likely pin→connector mis-attribution to review.
Still open: robust over-merge handling where a single drawn rail/bus connects
several connectors (e.g. shared horizontal rails), and wire-colour attribution on
the recovered connections (currently often null).