Skip to content

Adding a G code command

Sophie Déziel edited this page Sep 2, 2026 · 2 revisions

The interpreter doesn't hardcode its command set. Every supported gcode is an entry in a registry Map pointing at a small handler module, and adding a command is a four-file change: write a handler, export it, register it, test it. This page is the end-to-end recipe, plus the conventions that keep handlers consistent.

How dispatch works

A parsed command with gcode g1 is looked up in the handlers registry Map, which routes it to the linearMove handler module, which mutates job state, the current path, and stats and bounding box

The registry lives in src/interpreter.ts:

export type CommandHandler = (command: GCodeCommand, job: Job) => void;

export const handlers: ReadonlyMap<string, CommandHandler> = new Map([
  ['g0', linearMove],
  ['g1', linearMove],
  ['g2', arcMove],
  // …
]);

Interpreter.execute() looks each command up by its lowercase gcode and calls the handler if one exists — commands without an entry are silently ignored, which is how the library copes with the enormous surface of real-world G-code without special-casing it.

Three design points worth knowing before you write a handler:

  • Everything a handler needs lives on the Job. State, stats, and path bookkeeping (breakPath, resolvePosition) are job methods, so handlers are plain functions with no interpreter dependency. Handlers import the CommandHandler type with a type-only import, so registering them in interpreter.ts creates no circular runtime dependency.
  • One handler can serve many gcodes. linearMove handles both g0 and g1; selectTool reads the tool number out of command.gcode itself. The registry decides which spellings exist.
  • Configurable handlers use a factory. makeArcMove(options) builds an arc handler around its own tessellator; the Interpreter constructor swaps the g2/g3 entries in a copied map when arcChordTolerance is set. If your handler needs configuration, follow that pattern rather than module-level state.

What the parser already did for you

By the time a handler runs, the parser has normalized the command, and two guarantees simplify every handler:

  • Params are lowercase and finite. X10.5 arrives as params.x === 10.5; a malformed value like X1e999 or Xabc was dropped at the parse boundary, never passed through as NaN or Infinity. Handlers therefore use x ?? state.x freely with no finite-ness checks.
  • The gcode word is normalized. G01 becomes 'g1' (the number passes through Number(), collapsing leading zeros), T3 becomes 't3'. Register the normalized spelling only.

The recipe

Say you're adding support for command GXX.

1. Write the handler — src/interpreter/commands/my-command.ts:

import type { CommandHandler } from '../../interpreter';

/**
 * Executes a GXX command
 * @param command - GCodeCommand containing the command
 * @param job - Job instance to update
 */
export const myCommand: CommandHandler = (command, job) => {
  // read command.params, mutate job.state / paths / stats
};

Pick the existing handler closest to your command as a template:

Your command… Template Why
only changes state set-units.ts two-liner, the minimal shape
moves the nozzle linear-move.ts the full move pattern: path breaking, stats, bounding box
derives data from the gcode word select-tool.ts parses command.gcode itself
needs configuration arc-move.ts the make* factory pattern

2. Export it — add one line to src/interpreter/commands/index.ts:

export { myCommand } from './my-command';

3. Register it — add the entry (or entries) to the handlers map in src/interpreter.ts:

['gXX', myCommand],

4. Test it — src/__tests__/interpreter/commands/my-command.ts. The convention is to test the handler directly, no parser or interpreter in the loop: construct a GCodeCommand, run the handler against a fresh Job, assert the mutation. From the real set-units test:

import { test, expect } from 'vitest';
import { GCodeCommand } from '../../../parser/gcode-parser';
import { setInchUnits } from '../../../interpreter/commands';
import { Job } from '../../../job';

test('G20 sets the units to inches', () => {
  const command = new GCodeCommand('G20', 'g20', {});
  const job = new Job();

  setInchUnits(command, job);

  expect(job.state.units).toEqual('in');
});

test.each covers gcode families nicely (see the select-tool test running T0–T7). And note the coverage bar: the repo enforces 100% per-file coverage for everything under src/ by default, so every branch in your handler needs a case — run npm test and the threshold failure will tell you what you missed.

Conventions your handler must follow

These aren't style preferences; each one traces to a bug that shipped once.

  • ??, never ||, for coordinate fallbacks. X0 is a legitimate coordinate, and x || state.x treats it as "not given". This is safe only because the parser drops non-finite params — don't reintroduce || as a NaN guard.
  • e > 0 means extrusion. Exactly that. A negative E is a retraction, which is a travel move — the looser e ? check once rendered retracting arcs as phantom filament. If your command moves the nozzle, match linearMove's classification and its breakPath call when the type flips.
  • Resolve before you draw. Axes are undefined until homed. Write state first (state.x = x ?? state.x), then take job.resolvePosition() (un-homed axes resolve to 0) for the point you add to the path. Never put raw state.x into geometry.
  • Only extrusion grows the bounding box. Travel moves can wander far outside the print; calling boundingBox.update() on them breaks camera framing.
  • Zero-length moves are counted, not drawn. A command with no X/Y/Z still ticks the right job.stats counter (retraction, feedrate change, …). Keep the stats honest — the dev GUI and tests read them.
  • No side effects outside the Job. Handlers run during parsing/streaming, possibly thousands of times per second, on files of arbitrary origin. No logging per command, no DOM, no rendering — the renderer reads the job later.

Before you start: check the roadmap

Several currently-unsupported commands (the positioning family around G90/G91/M82/M83, G92 offsets and friends) have open issues with agreed designs — search the issue tracker before implementing, so your handler lands on the planned semantics rather than inventing parallel ones.

Where to look

What Where
The registry and CommandHandler type src/interpreter.ts
Handler modules src/interpreter/commands/
Handler tests src/__tests__/interpreter/commands/
The parser guarantees src/parser/gcode-parser.ts
The job toolbox (breakPath, resolvePosition, stats) src/job.ts

Clone this wiki locally