Status: design draft — the surface grammar. The generic engine that runs it
now exists as crates/gtl (Generic Template Language — it emits plain strings;
see gcode-engine.md §1.1). This document describes the GCode dialect the app
layers on top: the backtick/emit grammar plus the GCode-specific built-ins
(metric()/imperial(), unit-typed fmt). The old src/gcode/template.rs is
the rewrite target, superseded by gtl + the app-side Coder.
This document supersedes the "Strings between {} are interpreted as RHAI"
model described in Specification.md §6.6. That text-first model stays fine for
one-liners but collapses into unreadable string concatenation as soon as a
primitive needs a loop (see the manual peck cycle, below). GTL inverts the
default: a primitive is a Rhai script, and lines that emit GCode are marked.
- Modal units, tracked by the engine, not the author. A CNC coordinate
system is modal (
G20/G21). A value interpolated into emitted GCode is converted to the active unit system automatically. The author never writes.mmin the common case and never tracks which unit is live. - Simple stays simple. ~90% of primitives are a line or two of GCode with a few substitutions. Those must read as GCode with minimal ceremony.
- Complex stays readable. Loops and conditionals (manual peck, ramping,
arc-approximated beziers) are ordinary Rhai control flow that emits lines —
never manual
out += "..." + x.to_string()assembly. - Computation is typed; conversion happens at emit. Inside script logic,
values keep their unit type, so
z > z_bottomandz - peckare unit-correct. Conversion to a bare number happens only when a value is emitted.
A primitive template is a Rhai program. Every physical line is Rhai except
lines whose first non-whitespace character is a backtick (`); those are
emit lines. An emit line is verbatim GCode text in which each { expr } is
evaluated in the current Rhai scope, unit-formatted, and spliced in. The engine
transpiles each emit line into a single emit(...) Rhai statement, then runs the
whole thing as one script. Whatever is emitted, in order, is the primitive's
GCode output.
- A line is an emit line iff its first non-whitespace character is
`. - Indentation before the backtick is Rhai source layout and is discarded.
- Everything after the backtick (including spaces) is the emit payload, taken verbatim except for interpolation and escapes.
- A payload that also ends with a backtick is emitted without a trailing newline, so the next emit continues the same output line. The closing backtick is not part of the payload.
- Every other line is passed through to Rhai unchanged.
`G1 Z{z} F{z_feed} // emit line; leading spaces are code indentation
let z = z_retract; // Rhai line
while z > z_bottom { // Rhai line
A bare backtick emits a blank line:
` // => one empty output line
A closing backtick suppresses the newline, which lets one line be composed from several emits — a conditional prefix, or a fragment built in a loop:
`G54.1 P` // => "G54.1 P" with no newline; the rest follows
`` // => nothing at all
(This was once how line_format worked — it emitted a prefix and the application
appended the line. It now emits the whole line itself, so it no longer needs the
closing form. See §5.3.)
The closing backtick is a delimiter as much as a flag, and that is the point: trailing whitespace sits inside it, so the separating space above is visible in the source and survives an editor that trims line ends. Without a terminator that space would be both invisible and fragile.
A lone backtick is an opener with an empty payload, not an opener plus a closer, so it still emits a blank line.
- Inside an emit line,
{ expr }holds any Rhai expression, evaluated in scope. - Because braces delimit, no spaces are needed between fields:
X{x}Y{y}is valid and emits e.g.X3.2Y7.0. - To emit a literal brace, double it:
{{→{,}}→}. (GCode never uses braces, so this is rarely needed.) - Interpolation applies only on emit lines. On Rhai lines,
{ }is Rhai.
- Rhai lines use Rhai comments:
//and/* */. - GCode comments (
(...),;...) are just literal text and are only meaningful inside emit lines, where they are emitted verbatim.
Every { expr } is passed through the engine's formatter before splicing. The
formatter dispatches on the value's type and emits a bare number (no unit
suffix — the unit is implied by the modal G20/G21 state):
| Value type | Metric mode | Imperial mode |
|---|---|---|
Length |
millimetres | inches |
FeedRate |
mm/min | in/min |
RotationalSpeed |
rpm | rpm (unit-invariant) |
Angle |
degrees | degrees |
| integer / float | as-is | as-is |
| string | verbatim | verbatim |
Numbers are tidied to the unit system's display precision and rendered as
integers when whole (-40, not -40.000). This reuses the rounding already in
crates/units/src/display.rs.
Escape hatch. To force a specific unit regardless of mode, use an explicit
accessor that returns a plain number: { z.mm }, { z.inch },
{ z_feed.mm_per_min }. Those bypass modal conversion (a plain number formats
as itself).
metric()sets the formatter to metric and emits the machine's word for it.imperial()does the same for inches.- What they emit is the profile's, from its
set_unitprimitive — typically`{if metric { "G21" } else { "G20" }}, empty for a machine with no unit statement, and whatever a non-GCode controller wants otherwise. The engine holds no unit word of its own; it only knows that a machine has a unit mode. - The mode is program-scoped state: it is set once (normally in
program_begin) and persists across every primitive in the generated program, exactly mirroring the machine's own modal state. A drilling primitive does not re-set units; it inherits whateverprogram_beginestablished.
set_origin() is the third call of this shape, and the one that also validates:
- It emits the profile's
set_originprimitive, which receives the step fixture'sorigin_reference— the machine's own name for a stored zero (G55,G54.1 P7), exactly as the operator entered it. - Which offsets a controller has is a machine fact, so the primitive decides: it
normalises the reference and
throws when the machine cannot honour it. That refusal is deliberate and total — an unvalidated reference leaves the program running against whatever origin happens to be active, which is a board cut in the wrong place with nothing reported. - Unlike the unit mode, it sets no state. It emits one statement and is done.
comment("…"), message("…"), pause("…") are callable from any template and each
renders its own primitive with the call site's text bound as text:
comment("Outline pass"); -> ( Outline pass )
Nothing emits them automatically. They exist so a profile can annotate or interrupt its own output in its own dialect — the application supplies no wording and picks no placement.
They differ from the three above in when they render: their text is only known at the
call, so they cannot be pre-rendered. They render on a second engine whose output is
pushed into the enclosing one, because Gtl::run clears the shared output buffer on
entry and rendering in place would discard everything the caller had emitted. That
second engine deliberately has no callables registered, so comment() inside a comment
template is function-not-found rather than an infinite loop.
Both set_unit and set_origin are rendered once, at Coder construction, before any output exists —
so a broken template or a rejected origin refuses the program rather than truncating
it. That is also why the calls exist at all: Gtl::run clears the shared output
buffer, so a primitive cannot render another primitive in place.
This is the mechanism behind principle #1: the same call that tells the machine the unit tells the formatter the unit, so the two can never desync. Splitting them — letting a template emit its own unit word and set the mode separately — would allow a program in inch mode carrying millimetre numbers, with nothing to catch it.
Not a generator at all: it runs over every line of the finished program, receiving
index (non-blank lines, from 0) and text (the line as generated), and emitting the
line that replaces it.
`N{(index + 1) * 10} {text} -> N10 G0 X1 Y2
Two consequences of owning the whole line:
textmust be emitted, or the G-code is discarded and the program comes out as a column of bare line numbers.- Emitting nothing drops the line, which is how a profile removes one.
Numbering used to be a prefix the application appended to. That form is
indistinguishable from the destructive case above, so the variable it used (line) was
retired along with it: an un-migrated template fails to render rather than quietly
producing a numbered nothing.
Variables provided to a primitive are the units crate's typed values
(Length, FeedRate, RotationalSpeed, Angle) plus plain scalars and
strings. To make script logic natural, the engine registers, for each unit type:
- Arithmetic:
T ± T → T, unary-T,T * number → T(either order),T / number → T, andT / T → numberfor a ratio such as a pass count. - Ordering:
< > <= >= == !=, compared in canonical units — so10mmand1cmare equal, and a tie is judged within 1 nm so a depth loop terminates on its bound rather than on a rounding difference. - Helpers:
max(a, b),min(a, b),abs(a),clamp(v, lo, hi). These return one of their arguments unchanged, which is what makesz = max(z - peck, z_bottom)land exactly on the bound. - Accessors:
.mm,.cm,.inch,.milonLength;.mm_per_min,.in_per_minonFeedRate;.rpm;.degrees,.radians.
A typed value and a plain number cannot be compared. z > 5 is an error, not
false: five what? Write z > z_bottom, or take the number out yourself with an
accessor — z.mm > 5. The same holds across types (z > rpm) and for a name that
does not resolve. This is registered deliberately: Rhai's own answer to comparing
two unlike types is a silent constant, and a depth loop that quietly never runs
would emit a program with no cutting moves in it.
Division by zero is an error too, rather than an infinity that would reach the controller as a coordinate.
The engine rewrites each emit line into one emit(...) call and passes the rest
through untouched. emit(str) appends the string plus a newline to the output
buffer; fmt(v) is the formatter from §4. Both are also callable directly by
authors who want programmatic emission.
Emit line → Rhai:
`G1 Z{z} F{z_feed}
becomes
emit("G1 Z" + fmt(z) + " F" + fmt(z_feed));
Literal segments become string literals (", \, newline escaped; {{/}}
unescaped); each { expr } becomes + fmt(expr) +.
Source:
`G0 X{x} Y{y}
Transpiled:
emit("G0 X" + fmt(x) + " Y" + fmt(y));
Output (metric, x = 3.2 mm, y = 7 mm): G0 X3.2 Y7
`(Created by k2g from '{filename}' - {now()})
`(Step {step_index + 1} of {steps.len()}: {steps[step_index].name})
`(Reset all back to safe defaults)
`G17 G54 G40 G49 G80 G90
metric();
`G10 P0
`G0 Z{z_safe}
if has_positioning_pins {
`G56
} else {
`G54
}
Notes:
metric()emits the profile's unit statement at its position and fixes the unit for the whole program.- The
ifis a plain Rhai line — no wrapping braces. (The old text-first model needed{ ... }to "escape into" Rhai; here Rhai is the default, so control flow is written directly.) - Emit is line-oriented: a backtick is recognised only as the first
non-whitespace character of a line, never mid-expression. So the two branches
are broken onto their own lines rather than written inline as
if ... {G56 } else {G54 }(see §11).
// Manual peck cycle for controllers without a canned G83.
`G0 X{x} Y{y}
`G0 Z{z_retract}
let z = z_retract;
while z > z_bottom {
z = max(z - peck, z_bottom);
`G1 Z{z} F{z_feed}
`G0 Z{z_retract}
}
Transpiled:
// Manual peck cycle for controllers without a canned G83.
emit("G0 X" + fmt(x) + " Y" + fmt(y));
emit("G0 Z" + fmt(z_retract));
let z = z_retract;
while z > z_bottom {
z = max(z - peck, z_bottom);
emit("G1 Z" + fmt(z) + " F" + fmt(z_feed));
emit("G0 Z" + fmt(z_retract));
}
Compare against today's text-first equivalent in template.rs (SPEC_TEMPLATE2),
which is a wall of out += "G1 Z" + next_z.to_string() + .... Same behaviour,
readable.
`G83 X{x} Y{y} Z{z_bottom} R{z_retract} Q{peck} F{z_feed}
The grammar does not force loops on machines that have canned cycles; the simple form is unchanged.
template = { line } ;
line = emit_line | rhai_line ;
emit_line = ws , "`" , emit_payload , newline ;
emit_payload = { emit_text | interp | brace_escape } ;
emit_text = ? any chars except "{", "}", newline ? ;
brace_escape = "{{" | "}}" ;
interp = "{" , rhai_expr , "}" ;
rhai_line = ? any physical line whose first non-ws char is not "`" ? ;
rhai_expr = ? a Rhai expression, balanced braces, string-aware ? ;
ws = { " " | "\t" } ;interp scanning is brace-depth aware and string-literal aware (a } inside a
Rhai string in the expression does not close the interpolation) — the existing
parse_segments scanner in template.rs already implements exactly this and is
reused.
- Interpolation parse errors (unbalanced
{) are reported with the source line/column of the emit line. - Rhai parse/eval errors are reported against the author's source, not the transpiled script. The transpiler therefore maintains a line map (author line ↔ transpiled line). This must exist from day one; retrofitting it is painful.
- Per
Specification.md§6.6, evaluation must be deterministic for identical project inputs and must surface as typed diagnostics, never panics.
A template is a script with loops, so non-termination is an ordinary authoring
mistake — a while z > z_bottom whose body has not yet been written to decrease z.
The editor makes it worse rather than better: it previews on every keystroke, on the UI
thread, so the loop is executed while it is being typed.
The engine therefore caps one render at gtl::MAX_OPERATIONS (200,000) and reports
GtlError::Runaway, which names the likely cause rather than Rhai's own "Too many
operations". Two properties matter and are pinned by tests:
- The budget is per render, not per engine. One engine renders every primitive of a board; a cumulative counter would abort a real job part-way through and blame a template with no loop in it — a failure that scales with board size and so passes every small test.
- It bounds output too. A
while truethat emits cannot grow the buffer without bound, because each emit costs from the same budget.
The value is measured, not chosen by taste: a deliberately extreme 2000-pass loop costs ~20–30k operations, and the ceiling costs ~210 ms to reach in a debug build (~15 ms in release). That is ~7–10× headroom over the extreme case and ~200× over a realistic peck cycle, while keeping the worst case well short of reading as a hang.
Decision: emit is line-oriented. A backtick is recognised only as the first non-whitespace character of a line; inline emit (a backtick mid-expression) is deliberately not supported. This keeps the transpiler a pure line pre-pass with no Rhai parsing, at the cost of spreading a branch across a few lines. This was weighed against an inline form and rejected as not worth the scanner complexity.
Consequences:
- Emit detection runs before Rhai parsing. A continuation line of a Rhai
multi-line string literal that happens to begin with
`would be misread as an emit line. In practice primitives don't contain such literals; documented so it isn't a surprise. - Control flow that emits must break each emit onto its own line (see §8.2), not
inline as
if cond {A } else {B }.
- Static preamble: per-line backtick, or a raw block?
A large literal header (
program_begin) is ~10–15 backtick lines. Options:- (A, recommended) Per-line backtick everywhere. One uniform rule, no second syntax. Add a raw block later only if it bites.
- (B) Add a triple-backtick fenced block now: a line that is exactly
```opens/closes a raw region emitted verbatim (still honouring{ expr }), so headers need no per-line prefix. This choice shapes the line scanner, so decide first.
- Canonical variable set per primitive. The names used above (
z_safe,z_retract,z_bottom,peck,z_feed,x,y,rpm,slot, …) are illustrative. The authoritative per-primitive variable contract (and which areLengthvsFeedRatevs scalar) is owned by the primitive definitions and is TBD in a follow-up. - Keep
use_metric()/use_imperial()names, or rename tometric()/imperial()? This note uses the shorter names; the current engine usesuse_*. Cosmetic, but pick one before writing seed primitives.