For developers
GammaLoop Drawing Architecture
The current GammaLoop/Linnest/Kurvst drawing pipeline, DOT data ownership and style callbacks, with the superseded evaluated-field proposal kept behind an explicit historical boundary.
Status: Current contract; audited against the implementation on 2026-09-10
The pipeline, ownership, DOT parser fields, typed rendering data, path styles, and subgraph sections describe the implemented drawing stack. The superseded
*-evaland placeholder-interpolation design is preserved separately in the evaluated-field proposal record.
This document describes the drawing path used by GammaLoop and the DOT syntax that matters at each layer. The important boundary is:
- GammaLoop owns physics graph data, particle and momentum styling, and optional external-edge ordering or placement.
- Linnet/Linnest owns DOT parsing, half-edge graph shape, layout, and generic drawing primitives.
- Kurvst owns Bezier splitting, trimming, and path patterns.
- User-authored render templates are an optional drawing feature, not part of GammaLoop’s generated physics interaction.
Pipeline
GammaLoop drawing uses the same DOT files that describe the Feynman graphs. A rendering run has these steps:
- GammaLoop writes DOT graph files.
- GammaLoop writes a model-specific
edge-style.typ. This file maps particle names to Typst style dictionaries and exports callbacks namedsource-style,sink-style, andedge-label. - The drawing templates are extracted to
drawings/templates/. GammaLoop owns its app templates there (figure.typ,grid.typ,layout.typ,layout-core.typ), while the shared Linnest/Kurvst package files keep their canonical workspace layout underdrawings/templates/crates/{linnest,kurvst}/typst/. - Clinnet stages the input DOT and a generated Typst entrypoint for each graph, then the
linnetCLI compiles it with an external Typst 0.15 executable. Its thin DOT adapter parses that input and delegates graph styling, layout, and drawing to Linnest’s generic renderer. linnet-pyowns a separate in-process preparation boundary. It serializes only names, indices, incidence, flow, and orientation into a versionedlinnest-graph-specCBOR document; arbitrary Python.dataand DOTGlobalDatanever enter it. The wheel embeds Linnest, Kurvst, CeTZ 0.5.1, and oxifmt 1.0.0, stages them with the graph spec, and invokestypst-py0.15.0 directly. It has no Clinnet library dependency and does not discover or launch a Typst executable.- Both preparers invoke Typst with constant-sized process arguments. Their generated entrypoints statically import the selected template and each referenced user module once, materialize the native configuration dictionary, and call the template’s mandatory
render(config)export. Configuration is not flattened intosys.inputs; the Python entrypoint addsgraph-spec-path, while Clinnet addsdata-pathfor its DOT adapter. - Linnest owns the generic figure/layout implementation. It has no particle, momentum, amplitude, or cross-section mode. The GammaLoop path supplies its generated template bundle instead, binding the model-specific
edge-style.typcallbacks and interpreting GammaLoop-owned options for its generic, amplitude, and cross-section presets. - The DOT and Python adapters first construct a native Linnest graph, then call the shared
attach-elements(g, elements)operation. This usesgraph.mapnormalization for structural fields such as positions and spring lengths while preserving native Typst data. - GammaLoop optionally prepares the dangling endpoints, then supplies ordinary physics style callbacks and mode-specific
layout-defaultsto Linnest’slayout-graph(config, g). Linnest applies final labels and styles withgraph.style, measures them, translates generic constraints and subgraphs, executes layout passes, adjusts label offsets, and draws. Explicit pass options override the mode defaults. GammaLoop has no duplicate rendering pipeline. drawuses ordinary Linnest node and edge styles for a model-neutral graph. GammaLoop’s selected template installs the particle and momentum callbacks fromedge-style.typ. It draws edges and labels first, then nodes last so nodes sit on top of edges.
The rendering contract has no evaluated-string mode. Generated particle styles are ordinary Typst dictionaries/functions in edge-style.typ, backed by GammaLoop’s physics-edge-style.typ. Custom executable Typst enters only through an explicitly imported module; DOT strings and plain Python strings remain data.
Components
crates/gammalooprsGenerates physics DOT and the model-specific
edge-style.typ. It decides how a particle should look: photon wave, gluon coil, scalar dashed line, mass dependent thickness, labels, and related policy.crates/linnetProvides the half-edge graph data structure and DOT parser. Its parser turns invisible DOT nodes into dangling half-edges and preserves unconsumed attributes as statement dictionaries.
crates/linnestProvides the Typst-facing wasm plugin and Typst wrappers. It parses DOT or a versioned native graph spec into graph bytes, owns the generic
render(config)implementation, lays out node and edge positions, exposes graph queries, and provides the domain-neutral CeTZ draw API.crates/kurvstProvides the Typst-facing curve wasm plugin. It splits and trims Bezier curves, builds Hobby curves through edge layout points, and generates wave, zigzag, and coil path patterns.
crates/clinnetProvides the external-Typst CLI used to batch-render DOT files and assemble grid PDFs. Its generic template is a thin DOT-ingestion adapter over Linnest’s renderer. Every custom figure template must export
render(config).crates/linnet-pyOwns Linnet
HedgeGraphtopology through a selectable vector or forest node store. It exposes declarative construction, graph-bound subgraphs, topology algorithms and transformations, typed drawing metadata and render configuration, explicit DOT codecs, and module references. Subgraphs retain Linnet’s native half-edge set and add only the zero-crown node IDs that the set cannot represent, keeping isolated nodes visible to Python graph operations. Its rendering methods and notebook SVG display prepare a versioned CBOR topology and typed Typst configuration, then compile the staged standalone project in-process through thetypstPython package. It shares Linnest’s renderer with Clinnet but owns its native preparation and package staging.
GammaLoop’s particle policy is ordinary Typst styling and has no Python dependency. Its exported figure.typ is a DOT adapter requiring data-path; Python’s renderer supplies graph-spec-path instead, so selecting that figure directly from Python is not supported. Python callers can use generic DrawingSelectors or an imported Typst styling module, and retain Python’s separate data model. The editable Python physics example is an independent example, not GammaLoop infrastructure.
Data Ownership
GammaLoop DOT has two kinds of data.
Physics data is read by GammaLoop when a graph is parsed as a physics graph. Examples are particle, pdg, lmb_id, num, int_id, and overall_factor.
Generic drawing data is read by Linnest’s drawing template. Examples are display labels, half-edge compass points, and subgraph selections by compass. Particle and cut identifiers are GammaLoop template extensions rather than Linnest drawing fields.
The Python API makes this boundary explicit. Node.data, Edge.data, and HalfEdge.data retain arbitrary Python objects by identity; the renderer never inspects or serializes them. Their corresponding mutable drawing views accept only the closed native value model and template extensions. Python drawing selectors may inspect live element views, but only their validated results enter config.elements.
DOT conversion is a separate, explicit boundary. Python import and export require a DotCodec. The canonical Linnest codec retains its supported drawing subset while dropping arbitrary element .data; the topology codec drops element data and drawing. Both preserve graph and element names and the separate GlobalData. Rendering does not use either codec to guess a representation for arbitrary .data: it stages a versioned topology-only CBOR graph spec separately and sends typed drawing records through config.elements.
The same DOT file may contain both kinds of data for drawing. If a manually decorated DOT file is later fed back into GammaLoop’s physics parser, keep in mind that drawing-only fields may produce unknown-attribute warnings unless the physics parser has explicitly whitelisted them. The drawing templates treat GammaLoop-only physics fields as ordinary metadata unless a callback chooses to use them.
DOT Shape
Use DOT digraph syntax:
digraph demo {
graph [overall_factor="1"];
edge [particle="a"];
ext0 [style=invis];
ext0 -> v0:0 [id=0, is_cut=0];
v0:1 -> v1:2 [id=1, particle="d", lmb_id=0];
v1:3 -> ext1 [id=2, particle="a"];
ext1 [style=invis];
}Important parser rules:
style=invison a node marks that node as a dangling external half-edge. It is not a normal drawn node in the half-edge graph.- An edge from an invisible node to a real node becomes an incoming dangling half-edge at the real node.
- An edge from a real node to an invisible node becomes an outgoing dangling half-edge at the real node.
- An edge between two invisible nodes is invalid.
node:portandnode:port:compassare preserved as half-edge data. The port becomes the hedge id/port label; the compass is used by drawing subgraphs such assubgraph.compass(g, "e").edge [key=value]andnode [key=value]defaults are merged into individual edges and nodes before GammaLoop or Linnest sees the final statements.- Attribute values are handled as strings after DOT parsing. Quote complex expressions and values containing spaces or punctuation.
GammaLoop DOT Syntax
GammaLoop expects canonical attribute names. Some older aliases may be mentioned by warnings, but new DOT should use the names below.
Graph Attributes
numGlobal numerator factor. Default is
1.overall_factorSymbolica expression multiplying the graph. Default is
1.projectorOptional projector expression. If omitted, GammaLoop builds the polarization projector from external particles.
paramsSemicolon-separated Symbolica expressions used as additional parameters.
group_idOptional graph-group id. Graphs with the same group id are evaluated as one group.
is_group_masterBoolean marking the master graph inside a group. If no master is provided, GammaLoop chooses one.
Export-only graph attributes include overall_factor_evaluated; they are useful for inspection but are not input knobs.
Node Attributes
int_idUFO vertex-rule id. If omitted, GammaLoop infers the vertex rule from the oriented incident particles when possible.
numExplicit vertex numerator. If present, it is used instead of a UFO vertex rule.
dodDegree of divergence override for the vertex.
nameOptional semantic name stored on the parsed vertex. The DOT node id itself is still the graph topology handle.
GraphViz-only presentation fields such as label, shape, style, pos, color, and fillcolor are ignored by GammaLoop’s physics parser.
Edge Attributes
idNumeric edge id. Linnet consumes this as the internal edge index rather than keeping it as a normal edge statement. Drawing callbacks expose the drawn edge index as
eid.particleModel particle name, for example
"a","d","d~","g","ghG", or"W+".pdgAlternative to
particle; looked up through the model PDG code.massSymbolica mass expression. With
particle, this overrides the model mass for that edge. Withoutparticle, it creates a mass-only scalar-like edge.dirDOT direction/orientation.
forwardmeans default orientation,backmeans reversed, andnonemeans undirected. If omitted, GammaLoop derives the orientation from the particle.sourceHalf-edge payload for the source half-edge. GammaLoop expects JSON5 when the payload carries structured data, for example
source="{ufo_order:2}".sinkHalf-edge payload for the sink half-edge, with the same JSON5 convention as
source.lmb_idLoop-momentum-basis id for a chosen loop edge.
is_cutHedge id used to mark an initial-state cut/external cut.
numExplicit edge numerator. The parser localizes
edgeid(...),sourceid(...), andsinkid(...)placeholders to the concrete edge and hedge indices.dodDegree of divergence override for the edge.
nameOptional semantic edge name.
is_dummyBoolean marking a dummy edge. Dummy edges are filtered out of some physics operations and vertex matching.
momtrop_edge_powerOptional Symbolica expression controlling the momentum power used by the momtrop sampler. This does not change the graph topology.
vakint_edge_powerOptional integer controlling the momentum power used by vakint evaluation. This does not change the graph topology.
Physics DOT exporters may also write fields such as lmb_rep, dod_autogen, num_autogen, and name_autogen. These are inspection/export metadata, not normal user input.
Drawing DOT Syntax
The drawing templates receive the parsed graph after Linnest layout. All unconsumed DOT statements are available to callbacks as edge or node data.
Node Data In Drawing
The draw callback data for nodes includes:
vid: zero-based node index in the drawn graph.node: the full node object.name: the node name.- every node statement preserved from DOT.
By default, draw uses the node name as the label and computes a circle radius that fits the label. Users can override this in Typst with node-label and node-style callbacks.
Edge Data In Drawing
The draw callback data for edges includes:
eid: zero-based edge index in the drawn graph.edge: the full edge object.source-statement: source half-edge statement, if present.sink-statement: sink half-edge statement, if present.source-half-edge: source half-edge object with node, hedge, port, and compass data.sink-half-edge: sink half-edge object with node, hedge, port, and compass data.orientation:default,reversed, orundirected.ext: boolean, true for dangling half-edges.- every edge statement preserved from DOT.
GammaLoop’s generated edge-style.typ uses particle to look up the default edge style. A user can add drawing-only fields without affecting the generated GammaLoop styles.
Typed Render Configuration And Precedence
Graph.render_config stores typed GraphStyleOptions, ordered LayoutOptions, DrawOptions, generic DrawingSelectors, and the selected template’s closed native template_options. A sparse configuration supplied to render or to_svg overlays the rendering layers in this order:
selected-template defaults -> graph configuration -> per-call configurationThe Graph.render_config object itself is live and mutable, while its nested option objects are immutable values whose getters reconstruct copies. Replace style, layouts, drawing, selectors, or template_options after changing an option. RenderConfig.overlay returns a new configuration, and per-call overlays do not mutate the graph configuration. The template-options dictionary is open only in its string keys; every value still belongs to the validated native Typst model.
The shipped Python notebook display is always generic, even when topology has dangling edges. Core Linnet has no RenderMode, PhysicsOptions, or topology-based diagram inference. GammaLoop’s CLI and generated bundle select GammaLoop’s figure.typ and provide its mode and particle settings as template-owned options. Another application can select another template without teaching Linnet its domain vocabulary.
Attachment correlates node, edge, and half-edge records by topology index before optional domain preparation. Linnest composes edge layers once: graph defaults, explicit draw edge styles, record edge styles, draw endpoint styles, then endpoint-local styles. Native labels and callbacks remain Typst values. Explicit XY placement remains authoritative; GammaLoop’s external preparation always replaces dangling depth with a hard zero pin, including unmatched cuts and pre-positioned legs.
Generic Python selectors run before staging and may inspect arbitrary element .data. They return detached NodeDrawing, EdgeDrawing, or HalfEdgeDrawing values and fill only fields that the element did not set explicitly. Selector results never become Typst callbacks and arbitrary Python objects never enter config.elements.
Plain strings are escaped content. None, AUTO, and INHERIT have distinct native meanings; lengths, colors, strokes, marks, labels, layouts, routing, and the remaining finite choices use their value classes or enums. A TypstModule reference is the only executable extension point: its value, content, or function export is statically imported, and call/bind produce a validated reference. The renderer does not accept raw Typst expressions and does not call eval.
These rules complement the generated particle callback implementation in the GammaLoop-owned implementation its public callback API lives in the same GammaLoop template bundle.
GammaLoop Physics Styles And External Placement
The generated model map supplies line geometry and particle labels. The same callbacks work with DOT graphs and graphs built directly in Typst:
#import "physics-edge-style.typ" as physics
#import "layout-core.typ": autogen-external-edge-fields
#import "crates/linnest/typst/src/lib.typ": graph
#import "crates/linnest/typst/src/render/layout.typ" as renderer
#context {
let g = graph.build({
graph.node(<interaction>)
graph.edge(graph.sink(<interaction>), particle: "g")
})
g = renderer.attach-elements(g, (:))
g = autogen-external-edge-fields(g, graph: graph)
g = graph.style(g, ..physics.style(momentum-arrows: true))
renderer.layout-graph((:), g)
}Momentum arrows are opt-in. Their default combined label is the particle-map label followed by $q_(#edge.eid)$, in 10pt text by default; explicit labels take precedence. show-momentum: false and show-particle: false suppress the corresponding portion. Neither a cut sewing tag nor a momentum expression renumbers the edge ID.
The momentum geometry follows the Xbox example: offset 0.35, visible length 1.0, and no ratio cap. GammaLoop uses a rounded 1pt stroke and a straight arrowhead at scale 1.1. Its ink and accent colors are available through physics.palette; nodes use a transparent fill and a 1.45pt outline. The label gap is 0.20, reduced to 0.10 when momentum labels are disabled. Use momentum-arrow-side: auto, "left", or "right" relative to source-to-sink flow. Automatic sides follow the edge’s bend relative to its source-to-sink chord, independently of label visibility, size, or relaxed position. Straight edges, dangling legs, and closed chords use the signed offset as a deterministic fallback. The label carrier uses that same side; turning momentum labels off never selects the opposite side of the edge. momentum-arrow-shift moves the arrow; momentum-label-shift defaults to that shift but can be set independently. momentum-label-anchor controls label anchoring. These options also accept per-edge momentum-* fields. A full-path label-only: true, label: auto carrier reuses the ordinary measured label, preventing arrow-length changes from clamping its location. Explicit style arrays can remove the carrier; the ordinary edge label then supplies the fallback. Prepared external legs keep their combined labels outside the free endpoint. Their automatic anchor follows the actual direction from the attached node to that endpoint, so explicit placements can put incoming or outgoing legs on either side. Left endpoints anchor east, right endpoints west, and vertical endpoints anchor south or north; explicit user anchors take precedence. Their default momentum style leaves Linnest’s solved label position intact; explicit momentum shifts or momentum-label-anchor select the path-relative placement instead. Unmatched cut legs receive the same outward anchors. Amplitude and cross-section layout presets use label length scale 0.45 with momentum labels and 0.30 without them; explicit layout-pass settings still take precedence.
Use just draw --input debug=true to show Linnest’s ordinary node IDs $n_i$ and all half-edge IDs $h_i$, independently of momentum arrows. The just draw-debug shortcut writes drawings-debug.pdf. This selects ID labels without CeTZ canvas debug guides. Native style.node-label and draw.show-half-edge-ids overrides remain authoritative, and explicit endpoint labels retain Linnest’s normal precedence over the generated half-edge IDs. The typed GammaLoop entrypoint accepts the same flag as config.options.debug; the direct layout adapter accepts debug: true.
CLI paint overrides accept named colors and hex colors, for example just momentum_paint='#3d2645' draw-momentum.
Amplitude preparation orders incoming and outgoing edges by edge ID and places their columns. Cross-section preparation pairs sides through is_cut, places incoming legs in the left column and outgoing legs in the right column, and hard-pins each pair to the same Y coordinate. Explicit user XY placements retain precedence over automatic placement; supplying one axis does not disable automatic placement of the other. Whenever preparation runs, every dangling edge receives graph.pos(z: graph.pin(0)), even if already placed or missing a matching cut tag. Internal edges and node depths are untouched.
Pattern Style Dictionaries
Kurvst patterns are selected through Typst style dictionaries, not through a special physics DOT field. A style dictionary may contain:
pattern:"wave","zigzag","coil","normal", or"curve".pattern-amplitudepattern-wavelengthpattern-phasepattern-samples-per-periodpattern-coil-longitudinal-scalepattern-accuracy
Style dictionaries may also contain path-geometry keys. Linnest consumes these keys while resolving graph edge layers and delegates the actual path operations to Kurvst’s path-in/path-out helpers:
offset: normal offset for the half-edge path.length: maximum visible arc length for a centered parallel path.ratio: maximum visible fraction of the base edge length for a centered parallel path.resolve-length: how to combinelengthandratio.accuracy: Kurbo fitting tolerance for the parallel path.optimize: whether Kurbo should optimize the fitted path.offset-side: "label": choose the sign ofoffsetso the path is on the same side as the edge label.
draw also accepts edge-offset, edge-length, edge-ratio, edge-resolve-length, edge-accuracy, and edge-optimize as defaults for both half edges. Derived paths are computed on the base edge geometry before patterns and other decorations; node outsets then trim the shifted path, so it remains shortened at node boundaries. When both length and ratio limits are set, resolve-length decides which visible span to use. For paired edges, Linnest computes that centered visible interval once on the full edge and then projects it onto the source and sink halves, so a short layer can cross the source/sink split instead of being shortened independently on each half. The reusable operations live in Kurvst (layer, center-outset, segments, and length); Linnest’s draw layer only maps graph styles and label positions onto those primitives.
Kurvst follows CeTZ’s Rust boundary style: Typst sees explicit CBOR wire dictionaries and Rust converts those dictionaries to kurbo::BezPath internally. That keeps the public Typst API path-in/path-out without tying path objects to Kurbo’s serde representation.
GammaLoop-generated particle styles produce these dictionaries directly. Python callers express the same values through typed edge and half-edge drawing styles, or reference a dictionary exported by an explicit TypstModule. A DOT attribute is never interpreted as Typst source.
Subgraph Drawing
Subgraph shading is a drawing feature. In Typst, callers can construct a subgraph and pass it to draw:
#let east = subgraph.compass(layed-out, "e")
#draw(layed-out, subgraph: east)Compass subgraphs use half-edge compass data from DOT ports such as v:0:e or from Typst-built half-edge dictionaries such as (node: a, compass: "e").
draw shades included half-edges with subgraph-edge-style. By default this is an underlay, so the normal edge style remains visible on top.
Practical Guidance
For GammaLoop-generated diagrams:
- Put physics data in canonical GammaLoop fields.
- Let GammaLoop generate
edge-style.typ. - Use the GammaLoop DOT template through Clinnet, or its styling callbacks directly in Typst.
- Put executable custom styles in an imported Typst module, not in DOT strings.
For manually edited drawing DOT:
- Use
display-labelorlabelas data labels; templates do not expand{field}placeholders or recognizelabel-template. - Keep custom styles in the typed render configuration or an imported Typst module instead of a DOT string.
- Treat drawing-only DOT as a render artifact if those fields are not yet accepted by the GammaLoop physics parser.