Pipeline Architecture
This document describes the pipeline that turns a Doc model into machine-executable G-code. Since
the 1.9.0 rewrite the pipeline is built on raygeo intents: a declarative description of the work
the Rust side should perform, coupled with a thin Python orchestration layer and a refcounted
in-process artifact store.
The previous multiprocessing DAG (DagScheduler, PipelineGraph, ArtifactManager,
GenerationContext, WorkPiecePipelineStage) has been removed. This document describes the live
architecture only.
Core Concepts
Pipeline (Public Facade)
rayforge/pipeline/pipeline.py:40 — the class the rest of the application talks to. DocEditor,
ViewManager, UI widgets, and test code should depend on Pipeline only. IntentController and
IntentBuilder are implementation details of the facade and may change without notice.
Pipeline owns the ArtifactStore integration: it translates the raw raygeo outputs emitted by its
internal IntentController into refcounted artifact handles that the UI and export paths consume,
and exposes the signal/property surface the rest of the application expects (busy state,
pause/resume, recalculate, machine changes).
Key signals relayed by the facade:
| Signal | Meaning |
|---|---|
processing_state_changed | Busy/idle transitions |
workpiece_artifact_ready | A WorkPieceArtifact handle was published |
job_generation_finished | A JobArtifact handle (G-code + ops + estimates) ready |
job_time_updated | Aggregate time estimate changed during a rebuild |
data_stale | Rebuild requested but currently paused or manual mode |
visual_chunk_available | Progressive raster chunk for incremental UI updates |
IntentController
rayforge/pipeline/intent_controller.py:108 — owns a raygeo Intent and the surrounding rebuild
lifecycle. It listens to the same bubbled Doc signals the legacy pipeline used
(descendant_updated, descendant_transform_changed, descendant_added, descendant_removed,
job_assembly_invalidated) and rebuilds a raygeo Intent whenever the document changes.
On each debounced rebuild (200 ms REBUILD_DEBOUNCE_MS):
IntentBuilderis called to produce a fresh list ofNodeRequestobjects from the currentDoc.- The new list is wrapped into a raygeo
Intentviacreate_intent_from_nodes. Intent.updatediffs the previous intent against the new one using theversion_tokenper node and evicts any stale cache entries on the shared raygeoPipeline.- When
dispatch=Truethe new intent is also executed viarun_intent; theon_completedcallback performs the epoch filter (discarding results whosegeneration_idis older than the controller's current generation) and then marshals a DOM reattachment back to the application main thread via the shared task manager. - The
on_batch_progresscallback relays aggregate progress to listeners viaprogress_changed(marshalled onto the main thread so signal handlers never run on a rayon worker).
The
controllers _key_to_itemmap (rebuilt on every successfulIntentBuilder.buildcall) lets theon_completedepoch-filtered callback reattach outputs onto the originatingWorkPieceorStep`
without re-walking the Doc. Node keys are dispatched by shape:
| Node key | Reattached to | Signal emitted |
|---|---|---|
workpiece:{wp_uid}:{step_uid} | The owning WorkPiece | workpiece_artifact_ready |
step:{step_uid} | The owning Step | step_artifact_ready |
job | The Doc | job_aggregate_ready |
job:encode | The Doc | job_generation_finished |
IntentBuilder
rayforge/pipeline/intent_builder.py:133 — walks a Doc and produces a flat list of NodeRequest
objects with stable keys and deterministic version tokens. The builder is stateless: each
call to build produces a fresh, self-contained list suitable for wrapping in a raygeo Intent.
Stable Keys
workpiece:{wp_uid}:{step_uid}— one compute node per workpiece/step pair.step:{step_uid}— one aggregate node per step that concatenates the workpiece compute outputs and applies per-step transformers.job— one final aggregate node linking all step outputs with job-level markers and machine parameters.job:machinexform— machine-transform compute node that consumes the job aggregate's world-space ops and produces machine-space ops (curve linearization, rotary axis mapping, world→machine, WCS offsets, Z-flip, AXIS_REPLACEMENT).job:encode— encoder compute node that consumes the machine-transform node's ops and produces the machine code (G-code / vertex / texture).
The key formats are centralised in intent_builder.py so the producer and the IntentController
reattachment map always agree.
Version Tokens
raygeo's cache is keyed by node key only; the version_token is the sole invalidation signal.
Tokens are SHA-1 digests of a canonical representation of the inputs that affect a node's output
(see _hash_int, intent_builder.py:1066):
- Compute tokens hash
(geometry_revision, wp_size, step_params, assembler_params, per_workpiece_transformers). For step scopes declaring a position-sensitive transformer (seeStep.is_position_sensitive),transform_revisionof the workpiece and the stock revision are folded into the token; otherwise they are omitted so pure moves do not invalidate workpiece compute results. - Step aggregate tokens hash
(upstream compute tokens, placements, step_params, per_step/per_workpiece transformers, position_sensitive()), plusstock_revwhen the step is position-sensitive. - Job token folds in all per-step aggregate tokens so any upstream change (workpiece move, transformer edit, step param change) propagates through to the job/encode cache.
- Machine-transform token folds in the job token plus the machine identity (
supports_curves,reverse_z_axis, WCS config, rotary module config per layer). - Encode token folds in the machine-transform token plus the encoder identity (
driver_name,gcode_precision, axis extents, ...).
Stage Construction
Each NodeRequest carries a StageSpec describing the work raygeo should perform for that node.
The builder produces:
StageSpec.Computefor every workpiece/step pair viaStep.build_compute_payload(machine_defaults, workpiece), which returns aPart(vector geometry or image source) plus aComputePayload(assembler spec). Per-workpiece transformers (OverscanTransformer,BidirScanOffsetTransformer, ...) are resolved viatransformer_registryinto typed Rust*Specpyclasses and attached to the payload so the Rust compute stage applies them after assembly.StageSpec.Aggregatefor every step: oneAggregateGroupper upstream workpiece compute node, wrapped byWorkpieceStart/WorkpieceEndmarkers, with each input carrying the workpiece's world placement matrix and physical size astarget_dimensions. Per-step transformers (MultiPassTransformer,Optimize, ...) are attached toAggregateSpec.transformersso the Rust aggregate stage applies them after concatenation.MachineParamsis populated from the resolved machine so the aggregate's time estimate is correct.StageSpec.Aggregatefor thejobnode: oneAggregateGroupper layer wrapped byLayerStart/LayerEndmarkers, each containing oneAggregateInputper visible step; the whole aggregate is wrapped byJobStart/JobEnd.MachineTransformSpecforjob:machinexform: the world→machine 4×4 matrix, default and per-layer WCS offsets, per-layerRotaryMappingSpecentries, curve-linearization flag, and Z-reverse flag, packaged into a serialisable spec that the RustMachineTransformComputestage consumes.EncodeSpecforjob:encode: routes Grbl machines to the native RustGcodeSpec(compiled directly on a rayon thread without crossing the GIL) and every other machine to aPythonEncoderwrapping the driver-specific encoder callable. The encoder reads machine-space ops from the upstreamjob:machinexformnode.
Stock Resolution
_resolve_stock_geometries (called once per build and cached on the builder) returns the
world-space stock boundary geometries that transformers such as CropTransformer use to clip
per-workpiece ops to the machine's work area or explicit StockItems. Doc-owned StockItem entries
take precedence; the machine workarea rectangle is used as a fallback only when no doc stock exists.
raygeo Pipeline & run_intent
raygeo's Pipeline (raygeo.pipeline.execute.Pipeline) owns the cache that Intent.update
invalidates. run_intent schedules the intent's nodes onto rayon worker threads under the GIL and
invokes the on_completed callback per node and on_batch_progress for aggregate progress. Heavy
work (compute, raster, aggregate, machine transforms, encoding) runs in raygeo threads instead of
subprocesses, which is the headline change called out in CHANGELOG 1.9.0.
ArtifactStore & Artifact Handles
The legacy shared-memory ArtifactStore has been replaced by an in-process, refcounted store
(rayforge/pipeline/artifact/store.py:29). All artifacts live as plain Python objects in a dict
keyed by a UUID; handles carry the UUID in their key field plus any metadata the artifact type
needs. Lifecycle is managed through reference counting via ArtifactStore.retain / release.
The Pipeline facade translates raygeo outputs into artifact handles on the main thread:
| Output (raygeo) | Artifact | Stored under tag |
|---|---|---|
| Per workpiece-step ops | WorkPieceArtifact | wp |
| Per step aggregated ops | StepOpsArtifact | step |
| Job aggregate + encode | JobArtifact | job |
JobArtifact carries the world-space Ops, total distance, time estimate, the EncodedOutput
(text plus op→machine-code map), and — when rotary modules are configured —
kinematically-mapped ops for the 3D preview.
Generation IDs & Epoch Filtering
Each rebuild increments IntentController.generation_id. Every completed node carries the
generation it was spawned from. The on_completed callback compares the node's generation_id
against the controller's current generation and silently discards superseded results, so stale
outputs from a previous rebuild are never reattached to the DOM.
Pause, Resume & Manual Mode
Pipeline.pause()/resume()increment/decrement a pause counter on the controller. While paused, doc changes set adata_staleflag (and emitdata_stale) instead of scheduling a rebuild; on resume the flag is cleared and a rebuild is scheduled ifauto_rebuildis enabled.Pipeline.auto_pipeline=False(manual mode): recalculation is triggered explicitly viaPipeline.recalculate()rather than automatically on every doc change.
Invalidation Strategy
Invalidation is implicit and token-driven: any change that affects a node's inputs causes the
builder to produce a different version_token for that node's key. Intent.update evicts the stale
cache entry and raygeo re-executes only that node (and its downstream consumers).
| Change Type | Effect on Tokens |
|---|---|
| Geometry / params | New workpiece compute tokens cascade to step, job, machinexform, encode |
| Position / rotation | Workpiece compute tokens unchanged unless step is position-sensitive; step aggregate tokens always change due to folded placements, which cascades to job/encode |
| Size change | Same as geometry: tokens cascade from workpiece-step pairs upward |
| Stock items visible/moved/added | Affects stock_rev (folded into compute & aggregate tokens of position-sensitive steps) |
| Machine config | All of job:machinexform and job:encode tokens change; step compute/aggregate tokens change if kerf_mm / cut_speed / laser head / arc tolerance / supports_curves / supports_arcs change |
Detailed Breakdown
Input
The process begins with the Doc Model, which contains:
- WorkPieces: Individual design elements (SVGs, images) placed on the canvas
- Steps: Processing instructions (Contour, Raster, etc.) with settings, organised into a
per-layer
Workflow - Layers: Grouping of workpieces, each with its own workflow, WCS and rotary config
- StockItems: Optional explicit stock boundaries used by position-sensitive transformers (e.g. CropTransformer)
Python Orchestrator
Pipeline (Facade)
The Pipeline class:
- Listens to the Doc model for changes via signals (relayed through the
IntentController) - Debounces changes (200 ms reconciliation delay)
- Coordinates with the
IntentControllerto trigger regeneration - Manages the overall processing state and busy detection
- Supports pause/resume for batch operations
- Supports manual mode (
auto_pipeline=False) where recalculation is triggered explicitly - Connects signals between components and relays them to consumers
- Publishes refcounted artifact handles into the
ArtifactStore
IntentController
The IntentController:
- Owns a raygeo
Intentand the surrounding rebuild lifecycle - Rebuilds a fresh intent on every debounced doc change
- Executes the intent via
run_intentwhendispatch=True - Filters superseded results by
generation_id(epoch filter) - Marshals DOM reattachments onto the main thread via the shared task manager
IntentBuilder
The IntentBuilder is stateless; each build call walks the Doc and produces one NodeRequest
per workpiece/step pair, one aggregate per step, and the job, job:machinexform, and job:encode
nodes. See Stable Keys, Version Tokens, and
Stage Construction above.
raygeo Pipeline
run_intent schedules node execution on rayon worker threads under the GIL. The shared
RaygeoPipeline instance holds the node cache keyed by node key; Intent.update is the sole
invalidation entry point. Compute, raster, shrinkwrap, wavefront, contour, view rendering, and
machine-transform/encoding all run in raygeo threads.
Artifact Generation
WorkPieceArtifacts
Generated for each (WorkPiece, Step) combination. Contains:
- Toolpaths (
Ops) in the workpiece's local coordinate system - Scalability flag and source dimensions for resolution-independent ops
- Generation ID
Large raster workpieces are processed incrementally in chunks (relayed through
visual_chunk_available), enabling progressive visual feedback during generation.
StepOpsArtifacts
Generated for each Step, consuming all related WorkPieceArtifacts:
- Combined
Opsfor all workpieces in world-space coordinates - Per-step transformers applied (
Optimize,MultiPass, ...)
JobArtifact
Generated when G-code is needed, consuming the job aggregate and the job:encode node:
- Final machine code (G-code or driver-specific format) via
EncodedOutput(text + op→machine-code map) - World-space
Opsfor simulation and playback - High-fidelity time estimate and total distance
- Rotary-mapped ops for 3D preview when rotary modules are configured
2D View Layer (Decoupled)
The ViewManager is decoupled from the data pipeline. It handles rendering for the 2D canvas based
on UI state.
RenderContext
Contains the current view parameters (pixels per millimetre, viewport offset, display options).
WorkPieceViewArtifacts
The ViewManager creates WorkPieceViewArtifacts that rasterize WorkPieceArtifacts to screen
space, apply the current RenderContext, and are cached and updated when context or source changes.
Re-rendering is throttled (33 ms interval) and concurrency- limited; progressive chunk stitching
provides incremental visual updates. The ViewManager indexes views by (workpiece_uid, step_uid)
to support visualizing intermediate states of a workpiece across multiple steps.
3D / Simulator Layer (Decoupled)
The 3D visualization and simulation system is decoupled from the data pipeline, following a similar
pattern to the ViewManager. It consists of:
- A Scene Compiler that runs in a subprocess to convert
JobArtifactops into GPU-ready vertex data - An OpPlayer that replays the job's ops for real-time machine simulation with playback controls
Both consume the JobArtifact produced by the pipeline.
CompiledSceneArtifact
The Scene Compiler produces a CompiledSceneArtifact containing:
- Vertex layers: Powered/travel/zero-power vertex buffers with per-command offsets for progressive reveal
- Texture layers: Rasterized scanline power maps for engraving preview
- Overlay layers: Scanline power segments for real-time highlight
- Support for rotary (cylinder-wrapped) geometry
Compilation Pipeline
- Canvas3D listens for
job_generation_finishedsignals - When a new job is ready, it schedules scene compilation in a subprocess
- The subprocess reads the
JobArtifactfrom the store and compiles ops into GPU vertex data - The compiled scene is adopted back and uploaded to GPU renderers
OpPlayer (Simulator Backend)
The OpPlayer walks through the job's ops command-by-command, maintaining a MachineState that
tracks position, laser state, and auxiliary axes. This drives the 3D canvas playback (progressive
reveal of the toolpath), the machine head position and laser beam visualization, and per-command
stepping for the playback slider.
Consumers
| Consumer | Uses | Purpose |
|---|---|---|
| 2D Canvas | WorkPieceViewArtifacts | Renders workpieces in screen space |
| 3D Canvas | CompiledSceneArtifact | Renders full job in 3D with playback |
| Machine | JobArtifact (machine code) | Manufacturing output |
Key Architectural Decisions
-
Intent-based Scheduling: Instead of an explicit Python DAG with Python-resident schedulers, the pipeline declares what to compute (an
IntentofNodeRequests with stable keys and version tokens) and lets raygeo'srun_intentschedule the work on rayon threads. Cache invalidation is purely token-driven viaIntent.update. -
Facade + Internal Controller:
Pipelineis the single public surface;IntentControllerandIntentBuilderare implementation details. This keeps the public signal/property contract stable while allowing the orchestration internals to evolve. -
In-Process Artifact Store: Replacing the multiprocessing shared-memory store with a refcounted in-process dict removes the IPC and ownership-handoff complexity while keeping the handle/lifecycle contract the UI and export paths rely on.
-
Generation IDs: Each rebuild increments a generation ID; every completed node carries its spawn generation. The
on_completedepoch filter silently discards superseded results, so stale outputs are never reattached to the DOM. -
Main-Thread Reattachment: raygeo callbacks (
on_completed,on_batch_progress) fire on rayon worker threads under the GIL; the controller marshals every DOM-touching callback onto the application main thread via the shared task manager, so signal handlers never run on a worker. -
View Layer Separation: Both the 2D canvas (
ViewManager) and 3D canvas (Scene Compiler / OpPlayer) are decoupled from the data pipeline. Each is driven by pipeline signals rather than being part of the intent. -
Token-Driven Invalidation: There is no explicit invalidation table. The builder produces canonical SHA-1 version tokens; any input change produces a different token, which
Intent.updateuses to evict exactly the affected cache entries. -
Debounced Reconciliation: Doc changes are batched with a 200 ms debounce (
REBUILD_DEBOUNCE_MS) to avoid excessive pipeline cycles during rapid edits.