Skip to content

Latest commit

 

History

History
450 lines (352 loc) · 18.4 KB

File metadata and controls

450 lines (352 loc) · 18.4 KB

GCode Template Language (GTL)

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.


1. Design principles

  1. 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 .mm in the common case and never tracks which unit is live.
  2. 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.
  3. 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.
  4. Computation is typed; conversion happens at emit. Inside script logic, values keep their unit type, so z > z_bottom and z - peck are unit-correct. Conversion to a bare number happens only when a value is emitted.

2. The model in one paragraph

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.


3. Lexical rules

3.1 Line classification

  • 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

3.1.1 Continuing a 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.

3.2 Interpolation

  • 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.

3.3 Comments

  • Rhai lines use Rhai comments: // and /* */.
  • GCode comments ((...), ;...) are just literal text and are only meaningful inside emit lines, where they are emitted verbatim.

4. The type-driven formatter (fmt)

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).


5. Modal unit state

  • 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_unit primitive — 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 whatever program_begin established.

set_origin() is the third call of this shape, and the one that also validates:

  • It emits the profile's set_origin primitive, which receives the step fixture's origin_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.

5.1 The operator callables — arguments at the call site

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.

5.2 Pre-rendering

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.

5.3 line_format — the one filter

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:

  • text must 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.


6. Typed values in scope

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, and T / T → number for a ratio such as a pass count.
  • Ordering: < > <= >= == !=, compared in canonical units — so 10mm and 1cm are 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 makes z = max(z - peck, z_bottom) land exactly on the bound.
  • Accessors: .mm, .cm, .inch, .mil on Length; .mm_per_min, .in_per_min on FeedRate; .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.


7. Transpile

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) +.


8. Worked examples

8.1 move_slow — the 90% case

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

8.2 program_begin — establishes the modal unit

`(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 if is 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).

8.3 peck_drill — the payoff (manual cycle, no G83)

// 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.

8.4 peck_drill — machine with G83 (still a one-liner)

`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.


9. Grammar (EBNF)

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.


10. Errors and diagnostics

  • 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.

10.1 Runaway templates

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 true that 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.


11. Line-oriented emit (settled) and its limits

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 }.

12. Open decisions (need sign-off before coding)

  1. 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.
  2. 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 are Length vs FeedRate vs scalar) is owned by the primitive definitions and is TBD in a follow-up.
  3. Keep use_metric()/use_imperial() names, or rename to metric()/imperial()? This note uses the shorter names; the current engine uses use_*. Cosmetic, but pick one before writing seed primitives.