This document describes how ModView, the SystemVerilog module
interconnection viewer, is put together. It complements
.aiprj/AI_PRJ_DESIGN.md.
┌────────────────────────────── Browser ──────────────────────────────┐
│ index.html │
│ main.ts ── orchestration │
│ ├── ui/toolbar.ts file pick, parse, save/load, zoom, edit lock, │
│ │ re-layout, keyboard shortcuts │
│ ├── ui/sidebar.ts module list + top-module selector │
│ ├── ui/status.ts status / error bar (+ "View errors" link) │
│ ├── ui/errors.ts parse-error overlay panel │
│ ├── api/client.ts fetch POST /api/parse, GET /api/health │
│ └── diagram/ │
│ paper.ts JointJS Paper: pan, zoom, selection, edit lock │
│ nodes.ts module-instance + boundary-I/O elements │
│ ports.ts port groups (in / out / inout / interface) │
│ links.ts net-based link inference, trunks + styling │
│ hierarchy.ts nested-module (container) diagram building │
│ layout.ts ELK layered layout, drop re-route, re-layout │
│ routing.ts ELK edge sections → JointJS link vertices │
│ container.ts drop-time container refit │
└───────────────────────────────┬──────────────────────────────────────┘
│ multipart upload / JSON
┌───────────────────────────────┴──────────────────────────────────────┐
│ Backend (FastAPI) │
│ app/main.py app + CORS + / + /api/health │
│ app/api/parse.py POST /api/parse (upload limits, ok/partial) │
│ app/verible/runner.py invoke verible-verilog-syntax │
│ app/verible/cst_visitor.py CST → intermediate model │
│ app/models.py Port / ModuleDef / ModuleInstance / interface │
│ app/verible/verible_verilog_syntax.py (vendored Verible wrapper) │
└────────────────────────────────────────────────────────────────────────┘
POST /api/parsereads the multipart form with an elevatedmax_files, validates extensions and size limits, and writes the uploads to a temporary directory.runner.parse_filescallsverible-verilog-syntax --export_json --printtreethrough the vendored Python wrapper, yielding a concrete syntax tree (CST).cst_visitor.build_designwalks the CST:kModuleDeclaration→ModuleDef- ANSI ports from
kPortDeclaration; non-ANSI ports from headerkPortnames cross-referenced with bodykModulePortDeclarationdirections - interface-typed ANSI ports (
pcie_avst_if.slave tx) fromkInterfacePortHeader→direction: "interface"plusinterface_nameandmodport kInterfaceDeclaration→InterfaceDef, with body signals andkModportDeclaration→Modportviews- instantiations from
kInstantiationBase/kGateInstance; an instantiation whose type is a known interface becomes anInterfaceInstanceof the parent instead of a module instance - named connections from
kActualNamedPort; a.bus(axi_bus.master)signal is split intointerface_instance+modport
- Top-module candidates are modules never instantiated elsewhere.
- The temporary directory is removed and the design is returned as JSON.
The design is built from whatever Verible managed to parse. Files with
syntax errors do not block the rest: the response carries both the extracted
design and the per-file error list (see status: "partial" in
api.md).
In .port(signal) named connections Verible sometimes tags the port-name token
by its literal text instead of SymbolIdentifier. cst_visitor._dot_name
therefore locates the name positionally — the child immediately after the
. token — rather than by tag.
For interface-typed ports, iter_find_all's traversal order is not stable
between the nested and the direct identifier of a kInterfacePortHeader, so
_interface_port_info walks the subtree with an explicit pre-order iteration
instead.
client.parseFilesposts the files and receives the design JSON.sidebar.setDesignfills the top-module dropdown (grouped into Top candidates / Other modules) and the click-selectable module list — any module in the design may be rendered as the top, not only a candidate.- For the selected top module,
hierarchy.buildHierarchy:- creates one boundary-I/O node per top-level port (
createIoNode) - creates one node per instance, with ports from the instantiated module's
definition (
createModuleNode) - infers links by matching signal names (
analyzeTopModule) - recurses into instances that themselves contain instances, embedding them as nested containers
- emits the ELK graph that mirrors the JointJS graph
- creates one boundary-I/O node per top-level port (
layout.applyHierarchicalLayoutruns ELK over that graph and writes the result back: node positions and sizes, ELK-computed port coordinates, and the edge routes (routing.applyElkRoutingToLinks).
ELK.js does both node placement and orthogonal edge routing
(applyHierarchicalLayout, elk.algorithm: layered, elk.direction: RIGHT,
elk.hierarchyHandling: INCLUDE_CHILDREN, elk.edgeRouting: ORTHOGONAL).
Because ELK computes every edge in the same pass, it can keep the lines from
overlapping each other as well as the node bodies. Its bend points become the
JointJS link vertices, and each link's own router is set to normal so JointJS
draws exactly the path ELK produced. Spacing scales with the design's
congestion (edges per node), so denser diagrams get wider channels.
Two events re-compute the wiring afterwards, and they behave differently:
- Dropping a dragged node (automatic). The node stays exactly where it was
released and no other node moves. The parent container refits around its
children on all four edges (
container.resizeContainerToFit), thenlayout.rerouteAllre-routes every link in the graph — not just the ones touching the dragged node — using JointJS'manhattanrouter. Routing each link independently keeps the drop position exact and every segment orthogonal, at the cost of ELK's cross-link channel separation. - The Re-layout button (explicit).
layout.fullRelayoutre-runs the whole ELK pipeline over the current graph, restoring initial-grade wiring. ELK chooses the node positions again, so a manual arrangement is intentionally discarded — that is the trade-off the button exists to offer.
.aiprj/AI_PRJ_REQUIREMENTS.md §7.9 records why the drop path cannot use ELK:
elk.layered treats a node's supplied position as advisory and reflows it, so
routing a drop through ELK would move the node the user just placed.
The diagram starts locked (paper.ts Diagram, editLocked = true). Locking
calls paper.setInteractivity(false), which disables JointJS' built-in
interactions — element drag, link creation, vertex and label editing. Custom
handlers are not covered by that call, so the selection click, the trunk
expand/collapse click and all drag-tracking listeners are individually gated on
the flag as well; drag tracking staying dormant is what guarantees a locked pan
never rewrites a link's vertices. While locked, pointerdown on a node or a
link pans the paper just like pointerdown on blank canvas.
Links are inferred per net by links.analyzeTopModule:
- Each connection's
signalstring is reduced to a base identifier — bit-selects (data_o[0]→data_o), interface member access (bus.cmd→bus) and leading unary operators (~rst→rst) are stripped; numeric / sized literals such as8'h01are ignored. - Every port wired to the same base signal forms a net. One driver is chosen (a known instance output or a top-level input); links are drawn from it to every other port on the net.
Link appearance encodes the signal: stroke width grows with bus width, clock
and reset nets get their own colours, and the centre label shows the signal
name with its bit range (data[31:0]).
A connection that binds an interface — either explicitly
(.bus(axi_bus.master)) or through a child port declared as
<interface>.<modport> <name> whose bound signal resolves to an interface
instance of the parent — carries the interface instance, interface name and
modport on the resulting link. All links sharing an interface instance between
the same two nodes are collapsed into a single trunk: twice the normal
stroke width, labelled interface.modport, and coloured by hashing the
instance name so the same bus always gets the same colour.
Clicking a trunk (while unlocked) expands it into faded per-signal links with the trunk kept as a dotted backdrop; clicking again collapses it. Expanding is purely visual — it never triggers a re-route.
The modport also decides which side of the node the port sits on: a
master-like modport drives, so its port is placed on the right edge; a
slave-like modport receives and is placed on the left.
If a sub-module's definition was not uploaded, its port directions are unknown. The analyser then synthesises a definition from the instance's own connections and infers each port's direction from how its net is driven (a port on a net with a known driver becomes an input; the sole unknown port on an otherwise-undriven net becomes the driver/output). This lets a design be viewed even when only the top-level file is provided.
Instances with no connections (e.g. SystemVerilog interface instances) are
not drawn as nodes; their names act as nets that wire other instances
together.
hierarchy.ts builds a nested diagram: an instance whose module contains
further instances is drawn as a container with those instances embedded inside
it (JointJS embed), recursively, with a depth/cycle guard. Each module
level's connectivity is computed by reusing analyzeTopModule, and its cell
ids are remapped to hierarchy-qualified ids (parent/child). ELK lays the
whole hierarchy out in one pass (elk.hierarchyHandling: INCLUDE_CHILDREN,
which also sizes each container to fit its children plus its ports and
labels), and routing.applyElkRoutingToLinks walks the result recursively,
adding each container's absolute offset to the bend points it contains —
JointJS works in absolute paper coordinates while ELK emits parent-relative
ones.
An instance that looks expandable but ends up with no rendered children (for example, one whose sub-instances are all connection-less interface instances) is demoted to a leaf node, because ELK would otherwise size the empty container as 0×0 and it would render as nothing at all.
The save format wraps JointJS' native graph.toJSON():
{ "version": "1.0", "topModule": "cpu_top", "graph": { "cells": [ ... ] } }version here is the save-format schema version, not the application
version (which is reported by GET /api/health and recorded in
../CHANGELOG.md). The two are deliberately independent.
The custom svmv.Module element is registered into the JointJS cell namespace
so that graph.fromJSON() can reconstruct saved diagrams. A file saved after a
drop stores router: manhattan with no vertices — the path is recomputed on
render — while one saved after an initial layout or a Re-layout stores
router: normal plus the ELK-computed vertices.