All docs
Rendering & payloads
How a deliver step shows geometry, a chart, or a legend — return a ViewerPayload and the framework persists and routes it to the view that watches the entity.
A pipeline doesn't only save results — it shows them. When a deliver step
puts geometry in the 3-D viewer, draws a chart, or adds a colour legend, it does so by returning a
ViewerPayload. This is the DELIVER-out half of the pipeline's I/O symmetry: a
load step takes a Source in, a deliver step returns a
ViewerPayload out, and the framework does the plumbing on both ends.
Return a payload, not an emit#
from lsdtools import Tool, Table, LayerPayload
tool = Tool("geo")
@tool.deliver
def show_lines(t: Table) -> LayerPayload:
"""Draw the incoming geometry as lines in the 3D viewer."""
return LayerPayload(kind="lines", tables={"path": t}, style={"width": 1.5})
The return annotation (-> LayerPayload) is what marks this deliver as viewer-delivering. When it
runs, the framework:
- writes each table in
tablesto Parquet in the step's workspace, - persists the
spec+ table paths + routing as a*_payload.jsonsidecar, - emits the payload's
topicwithentity_id/step_idstamped centrally, and - reconstructs it generically on reopen from the sidecar — no per-step code.
So you write one return instead of hand-rolling ctx.emit("viewer/layer/set", ...) plus your own
parquet-write and reconstruct-on-reopen dance. The three built-ins cover
the common surfaces:
| Return | Shows | Topic |
|---|---|---|
LayerPayload(kind=..., tables=..., style=...) |
3-D geometry (the Parquet rides in tables) |
viewer/layer/set |
ChartPayload(kind=..., title=..., series=...) |
a chart (spec-only by default) | viewer/chart/set |
LegendPayload(title=..., colormap=..., vmin=..., vmax=...) |
a colour legend | viewer/legend/set |
A plain deliver that only writes a file returns nothing — payloads are for showing.
Routing: a payload follows watch#
You never address a viewer by hand. A payload reaches whatever View
watches the entity that produced it. That single rule (RFC-0002 —
subscription is the routing rule) is the whole story:
- a viewer shows a delivered payload iff it watches that payload's
entity_id; - a viewer that does not watch the entity declines it — so two open viewers never disagree about who shows an untargeted layer;
- the desktop's drag-and-drop, the 3-D checkbox, and the right-click Show in ▸ all do the
same one thing under the hood: they call
view.watch(entity).
In a script you name the link yourself with the same verb — attach the view, watch the entity, run:
from lsdtools import Engine, views
engine = Engine("survey.lsd")
grades = grade_chart(...); engine.add(grades) # an entity whose deliver returns a ChartPayload
panel = views.chart(title="Grades"); engine.add(panel)
panel.watch(grades) # this view now shows grades' result — and re-runs refresh it
engine.run_all()
panel.show()
The desktop's built-in 3-D viewer is itself a View the shell mounts; watching an entity from it
(the checkbox / Show in ▸) is the same call. Because watch auto-pulls an entity that has
already run, a payload delivered before the view existed still appears the moment the view watches
the entity — which is why a reopened project draws immediately.
Terminal artifacts vs live streams#
Return a payload for a terminal artifact — the finished geometry, the final chart, the legend —
because the framework persists it and replays it on reopen. For live, mid-run signals — progress
ticks, deep-learning epoch metrics, log lines — keep using ctx.emit(...) on the injected
StepContext: those are transient events, not artifacts, and are not persisted.
A new viewer surface#
The three built-ins are not special — they are just registered ViewerPayload subclasses. To teach
the viewer a new kind of drawable, you add two pieces:
1. A payload subclass naming a new topic (see ViewerPayload):
from lsd.flow.payload import ViewerPayload, register_payload
@register_payload
class HeatmapPayload(ViewerPayload):
topic = "viewer/heatmap/set"
def __init__(self, *, tables=None, **spec):
super().__init__(spec=spec, tables=tables)
2. A sink on the front-end that subscribes to that topic and draws it — gating every inbound
event on should_handle, the shared per-view routing rule, so your new surface follows watch
exactly like layers and charts:
# gui-tier: a viewer surface's sink (mirrors lsd_viewer's chart/legend sinks)
async def _on_set(self, e):
data = getattr(e, "data", {}) or {}
if not should_handle(data, self._target_id, self._watched):
return # this heatmap belongs to a different viewer
spec = data.get("spec") or {} # your topic's payload shape
... # build a primitive, register it on the canvas
The sink reads its spec from data["spec"] and any table paths from data["paths"][key] — the same
framework-stamped shape every payload carries. register_payload and the sink both live in the
gui/product tier (lsd.flow.payload + lsd_viewer), which is why a new surface is a front-end task,
not a pure lsdtools package one. The built-in geometry, chart, and legend sinks are the reference
implementations.
Related#
Learn — Deliver steps · How a package fits together · Naming: which is which
API — ViewerPayload · View · watch · Tool.deliver
Examples — Clean a CSV · Multi-output mesh
Frequently asked questions
How does a deliver step show geometry or a chart in the viewer?
Return a ViewerPayload from the step. LayerPayload(kind=..., tables=..., style=...) draws 3D geometry, ChartPayload(kind=..., title=...) shows a chart, LegendPayload(title=..., colormap=...) shows a colour legend. The framework writes the tables to Parquet, persists a *_payload.json sidecar, and emits the payload's topic — you never call ctx.emit for a terminal artifact.
Which viewer does a delivered payload show up in?
Whichever view watches the payload's entity. Routing is by subscription (RFC-0002): a viewer shows a payload iff it watches that payload's entity_id. Two open viewers never disagree about who shows an untargeted layer — a viewer that does not watch the entity declines it.
When should I still use ctx.emit instead of returning a payload?
For live, mid-run streams — progress ticks, DL epoch metrics, log lines. Those stay ctx.emit events. Payload return is for terminal deliver artifacts only (the finished geometry, the final chart), because the framework persists and replays them on reopen.