Domain styles and templates

Linnest provides generic graph styling and drawing callbacks. Application domains compose those primitives in ordinary Typst code or provide a custom V1 template; there is no built-in physics module.

GammaLoop owns its particle-line, momentum, amplitude, and cross-section template. Those policies are available when rendering through GammaLoop’s template bundle, not by importing them from Linnest. See GammaLoop’s physics drawing template.

Concepts

Domain Styles And Templates

Linnest’s drawing layer is domain-neutral. A caller can map arbitrary node, edge, and half-edge data to reusable draw callbacks without changing topology or adding a domain-specific core option. The following map uses an application-defined kind field; the names and styles have no meaning to Linnest itself:

#import "../src/lib.typ": draw, graph, layout
#import graph: build, edge, node, sink, source

#let kinds = (
  dependency: (stroke: rgb("#315f9f") + 0.8pt),
  event: (
    stroke: rgb("#a24b36") + 0.8pt,
    mark: (end: ">"),
    mark-position: "center",
    mark-orientation: "edge",
  ),
)
#let edge-style(edge) = kinds.at(edge.fields.at("kind", default: "dependency"))
#let g = build({
  node(<queued>, label: [queued])
  node(<running>, label: [running])
  node(<done>, label: [done])
  edge(source(<queued>), <starts>, sink(<running>), kind: "event", label: [starts])
  edge(source(<running>), <needs>, sink(<done>), kind: "dependency", label: [needs])
})
#let g = graph.style(g, edge-label: edge => edge.label)
#draw(layout(g, layout-algo: "stable-layered"), edge-style: edge-style)

The Feynman example style is configured before layout. graph-style is a function returning options for graph.style; draw-style remains a dictionary of drawing callbacks and defaults:

#import "../examples/map-style.typ" as feynman
#let styled = graph.style(g, ..feynman.graph-style(
  unit: 1.35, line-width: 0.7pt, node-radius: 0.2,
  momentum-line-width: 0.4pt,
))
#draw(layout(styled), ..feynman.draw-style)

Call feynman.graph-style() for the original appearance; replace the former dictionary spread ..feynman.graph-style with ..feynman.graph-style(). Its named options are:

  • unit: 1.35: graph coordinate unit; a number multiplies the current 1em, or supply an absolute length such as 10pt.
  • line-width: 0.5pt: ordinary particle stroke thickness.
  • node-radius: 0.18: visible node radius in graph units.
  • node-line-width: auto: node outline, defaulting to line-width.
  • massive-line-width: auto: scalar/ghost stroke, defaulting to 2 * line-width.
  • fermion-arrow-line-width: auto: fermion arrowhead outline, defaulting to 0.4 * line-width.
  • momentum-line-width: auto: momentum shaft and arrowhead thickness, both defaulting to 0.8 * line-width.

Width overrides are Typst lengths, independent of the graph unit and node radius. With all defaults, the node and ordinary edge strokes are 0.5pt, scalar/ghost strokes 1pt, fermion arrowheads 0.2pt, and momentum strokes 0.4pt. The constructor stores resolved node-style, fermion, particles, momentum-stroke, and momentum-mark presets in scope.feynman. graph.style uses this scope for measurement, and draw inherits it after layout; no repeated draw-time configuration is needed. Separate styles do not share configuration. Direct calls to the exported feynman.node-style and feynman.edge-style without scope still use the original defaults.

Particle aliases remain a/photon, g/gluon, and scalar/ghG; other particle names use the fermion preset. Hidden nodes always have radius zero and no fill or stroke. Per-edge momentum fields, including sparse mom(...) patches described below, retain priority over inherited edge data and keep their arrow/label placement independent of these width options.

Momentum visibility belongs to edge data: set show-momentum: false on an edge(...) or in a graph.map edge patch to omit its momentum arrow and label while retaining the propagator and fermion arrow. It defaults to true. Set default-edge-data: (show-momentum: false) on graph.build to hide all momenta, then opt individual edges back in with show-momentum: true. Cut fragments inherit the flag like other edge data. Use ..feynman.draw-style unchanged; edge-style no longer takes a show-momentum argument. Only drawing layers are suppressed: physical momentum data is retained, and hidden layout labels still participate, so the switch does not change solved graph geometry.

An edge can sparsely patch the edge-style passed to draw with style: (...), compute the patch with a callback, use auto to delegate, or use none to hide its paint while retaining topology. Local style callbacks receive the complete post-layout drawing record. Custom templates can package the same policy behind their mandatory render(config) export; the selected application owns the meaning and schema of any extra options.

Set edge-offset on draw, or offset in a source-style/sink-style dictionary, to draw a fitted parallel path. Style callbacks may also return an array of dictionaries; the layers are drawn in order on the same graph edge, so parallel strokes do not require duplicate graph edges.

#let base-style(edge) = (
  stroke: (paint: gray, thickness: 0.7pt, cap: "round"),
)
#let offset-style(edge) = (
  offset: 0.18,
  length: 1.4,
  ratio: 0.5,
  resolve-length: "min",
  shift: 0.2,
  stroke: (paint: rgb("#2f6f4e"), thickness: 1pt, cap: "round"),
  label: [active],
  label-side: "left",
  label-gap: 0.12,
)
#let source-style(edge) = (base-style(edge), offset-style(edge))
#let sink-style(edge) = (base-style(edge), offset-style(edge) + (mark: (end: ">")))

The parallel path is applied to the base edge geometry before patterns and other decorations; node outsets then trim the shifted path, so shifted paths still start and end outside fitted node circles. Add edge-length or length to center-trim the shifted path to a fixed arc length, and add edge-ratio or ratio to cap it by a fraction of the full offset path length. edge-resolve-length

resolve-length decides how to combine both limits
"min"/"shorter" (default), "max"/"longer",

"length"/"fixed", "ratio"/"relative", "none"/"full", or a function receiving (offset-path-length, length, ratio). Lengths and shortening distances are measured after offsetting, since a curved parallel path need not have the same length as its centerline. For a finite layer, shift is an arc-length displacement along the complete offset path: positive values move toward the path end and values that would cross an endpoint are clamped. A layer can attach label content to its own path. label-shift (default 0) moves the label’s reference point by signed arc length from that derived path’s midpoint, clamped to its endpoints: clamp(path-length / 2 + label-shift, 0, path-length). Positive values move toward the path end, without moving or trimming the layer itself. label-side chooses "left" or "right" relative to the local path direction at the shifted point; auto follows the side selected by ordinary edge-label layout. With label-style.anchor omitted or set to auto (also "auto"), the label is centered and moved until its entire CeTZ box clears the local tangent line by label-gap. This measures the box, including text bounds, wrapping, padding and rotation, not the nearest point of the finite curved shaft. An explicit anchor, including "center", instead sits at the shifted reference point plus label-gap along the chosen normal, without any box-clearance correction. Negative gaps are clamped to zero in both modes. Other label-style fields are forwarded to CeTZ content drawing. Automatic clearance is a local tangent-line heuristic, not collision avoidance; explicit anchors intentionally allow the box to cross that line. In examples/map-style.typ, momentum-label-anchor selects this behavior: omit it or use auto for box clearance, or choose e.g. "east" for direct anchor placement. momentum-label-gap supplies the gap in graph units. The momentum label uses a separate full, unpainted offset-path layer, so momentum-label-shift is independent of momentum-arrow-length, even when arrow and label shifts are equal. It defaults to the requested momentum-arrow-shift, not the arrow’s clamped center. Near endpoints, the label point can therefore travel farther than the finite arrow’s center; changing arrow length never moves the label.

Import momentum as mom from examples/map-style.typ for compact, sparse options. mom(side: "right", length: .7, shift: -.4,
label: (gap: .2, shift: -.75, anchor: "south-west"))
expands to the existing momentum-arrow-* and momentum-label-* fields. Arrow options are side, offset, length, and shift; label options are gap, shift, and anchor. Only supplied options are emitted, so mom(label: (gap: .2)) preserves an inherited arrow offset or label shift. Combine the result with other edge patches using dictionary addition, or spread it into edge(..).

The attached label replaces the ordinary painted edge label, while that ordinary label may still supply the pre-layout size used by label layout and side selection; attached labels do not add a second collision constraint. When compatible source and sink layers request a centered mark, Linnest emits that mark once on the complete derived layer. The mark therefore follows shift instead of being centered separately on either half-edge. Set offset-side: "label" on an offset layer to choose the sign of offset so the layer is drawn on the same side of the curve as the edge label.

Pattern layers are generated over each continuous path, so internal curve segments do not restart the phase or shorten the endpoint taper.

pattern-natural-endpoints defaults to false. For built-in coil strings ("coil", "helix", or "spring") on complete continuous paths with both endpoints anchored, true constructs a fitted kurvst.coil dictionary in Typst from the path length and layer’s pattern settings, then applies it once through ordinary kurvst.pattern. Its half-integer coil periods retain full amplitude, inward endpoint phases, and exact endpoints, without taper, stubs, or connectors. This overrides pattern-phase, bypasses pattern-fit integer fitting, and ignores pattern-endpoint-slope (endpoint-ramp: false).

Other patterns ignore the flag. A fitted dictionary can be passed directly as pattern without this flag, with matching pattern-* settings; see the Kurvst manual’s Path Patterns section for fitting and one-pass application. On curved carriers, fitted coils use local tangent/normal offsets, not evaluation at a corrected arc distance.

The gluons in examples/xbox-common.typ enable this option on top of the examples/map-style.typ preset. Without it, anchored coils use a 75%-wavelength endpoint taper (capped at half the path length), with a squared longitudinal envelope and no straight end sections. Without automatic fitting, pattern-fit: true adjusts pattern-wavelength to the nearest whole number of periods on a complete edge; pattern-phase is in radians, with calc.pi / 2 giving coils matching endpoint phases. Set pattern-endpoint-slope: 1 on a layer to allow an angled endpoint instead of a tangential one; the default is 0 and the valid range is 0 to 3. This controls the taper envelope, not an angle, so the final direction also depends on phase, amplitude, and wavelength. Endpoints remain attached.

When source-style and sink-style share a pattern and differ only in stroke, for example to colour the two halves of a gluon differently, the whole edge is patterned once, fitted like a single edge, and cut at the edge layout point. Each half keeps its own stroke while the coil runs continuously through the split. Other split-style halves and partially anchored paths, such as crossing-gap fragments, keep the requested wavelength, taper only anchored endpoints, and preserve phase continuity across hidden spans. Neither automatic coil construction nor integer fitting applies. A path with gaps is patterned per continuous run, carrying the phase across each gap.

Paired edges are Kurvst paths split at their edge layout point. Set edge-split-gap on draw to open a centered arc-length gap there, or set split-gap on an individual source/sink style layer to override the global value for only that layer. Wave and coil phases continue across the hidden span. edge-halves and to-cetz-edge-halves accept the same split-gap control and report the effective gap if either half is too short. This cuts at edge.pos; it does not detect arbitrary crossings.

For a known centerline crossing, put crossing-under: 5 (using the target’s integer edge id) or crossing-under: <bridge> (using its Typst edge name), and optionally crossing-gap (default 0.55) on the style layer that should be interrupted. Targets are resolved independently of edge order. Kurvst locates proper intersections and trims the current path by arc length; wave and coil phases continue across every hidden span. This works for dangling layers and for paired layers whose source and sink have one continuous style. Both paired half styles must name the same target and gap. A cut layer’s mark is placed once on the original uncut carrier, while only its painted path is split; this keeps the mark present without duplicating it on every fragment. A cut layer cannot participate in a subgraph underlay. Self, unknown, and invisible references are reported as errors. A valid edge pair with no proper interior intersection, including one that only shares an endpoint, is left unchanged.

#let crossed-style(edge) = if edge.eid == 7 {
  (stroke: black, pattern: "coil", crossing-under: 5, crossing-gap: 0.55)
} else {
  (stroke: black)
}

For dangling edges, edge-dangling-tangent: "horizontal" or "vertical" constrains the tangent at the free edge position while retaining the edge’s bend; the per-layer override is dangling-tangent. auto keeps the ordinary bent route, and anchor-control-distance controls the constrained handle length. bend affects dangling edges only. A paired edge instead follows its source node, edge.pos, and sink node, with edge-omega controlling its Hobby curve.

For anchored paired edges, anchor-control-distance sets the control-handle distance in graph units, not the endpoint position. Each edge can specify source-style: (anchor-control-distance: 4) and sink-style: (anchor-control-distance: 1.5) to tune the two ends independently. Set source-anchor and sink-anchor in its edge-style to choose the tangent directions. A shared edge-style: (anchor-control-distance: 2) supplies both ends unless an endpoint style overrides it; auto is computed independently for each end. An override no longer supplies the opposite endpoint’s distance.

#draw(
  g,
  edge-split-gap: 0.28,
  edge-dangling-tangent: "horizontal",
  source-style: edge => (stroke: black, split-gap: 0.4),
  sink-style: edge => (stroke: black, split-gap: 0.4),
)

Data defaults are also the global styling hook for all sources, sinks, nodes, and edges. More specific data can be added with captured named arguments on node, edge, source, and sink items, or by running graph.map/graph.eval-fields after construction. This keeps evaluated Typst values in the native data channel instead of adding renderer-specific eval fields to the Rust topology spec:

#let g = build({
  node(<a>)
  node(<c>)
  edge(
    source(<a>),
    <a-c>,
    sink(<c>),
    label: [a-c],
    kind: "highlight",
  )
},
  name: "demo",
  default-source-data: (style: (stroke: red + 0.5pt)),
  default-sink-data: (style: (stroke: blue + 0.5pt)),
)
#let endpoint-style(edge, side) = {
  let endpoint = edge.at(side + "-half-edge", default: none)
  if endpoint == none { return (:) }
  endpoint.data.at("style", default: (:))
}
#draw(
  layout(g),
  source-style: edge => endpoint-style(edge, "source"),
  sink-style: edge => endpoint-style(edge, "sink"),
)

graph.build also accepts comma-separated items:

#let g = build(
  node(<a>),
  node(<b>),
  edge(
    source(<a>, compass: "e"),
    <ab>,
    sink(<b>, compass: "w"),
    label: [ab],
    statements: (color: "0055ff", label: "ab"),
  ),
  name: "demo",
  statements: (full_num: "x + y"),
  default-node-statements: (shape: "circle"),
  default-edge-statements: (
    color: "000000",
    display-label: "$#label$",
  ),
)