Skip to content

Add axis following (M604) - #1265

Open
meeloo wants to merge 12 commits into
Duet3D:3.7-devfrom
meeloo:upstream/axis-following-m604
Open

Add axis following (M604)#1265
meeloo wants to merge 12 commits into
Duet3D:3.7-devfrom
meeloo:upstream/axis-following-m604

Conversation

@meeloo

@meeloo meeloo commented Aug 22, 2026

Copy link
Copy Markdown

Makes one axis track another as part of the same coordinated move, rather than reacting to it after the fact.

The motivating case is a Z-independent dust shoe on a U axis: the shoe is carried on the Z carriage, so U must move the opposite way as Z plunges to keep the bristles on the material. Done with G-code macros polling Z and issuing corrective moves, this visibly lags. Done in the planner, it does not.

M604 A"<follower>" B"<leader>" [S<scale>] [O<offset>] [E<0|1>]

A is the driven axis, B the tracked one, S the scale (default -1, since a follower carried on the leader must move the opposite way to stay put), O an offset, E engages or disengages. The rule is follower = S * leader + O in machine coordinates, clamped to the follower's M208 limits. No parameters reports state.

Engaging without O captures the current relationship, so the follower does not jump when it engages. Engaging is refused if the follower is not homed.

How it works. AxisFollower::Apply runs at the end of ToolOffsetTransform, so following applies to every kind of motion - straight moves, arcs, and jogging - without any of them knowing about it.

Measured skew between leader and follower: 0.0000 ms across straight moves, helical arcs and jogging, on an emulator with step pulses timestamped on the parallel IO port.

Also exposed in the object model as move.axisFollower (engaged, follower, leader, offset, scale) so a UI can show the state.

Documented in Developer-documentation/Axis following (M604).md.


Two things to know before reviewing:

  1. This branch stacks on the M700 branch. Of the 9 commits, the first 6 are the velocity-jogging PR and only the last 3 are M604. AxisFollower touches JogController so the two cannot be separated cleanly. Review the last three commits, or merge M700 first and this will shrink to them.
  2. The command number is a proposal, same as M700 - happy to renumber

meeloo and others added 6 commits August 22, 2026 11:41
Adds a second way to command motion: signed speed per axis, instead of a
destination, so that an analogue input such as a joystick can drive the axes
directly. M700 X25 Y-12 means "X at +25 mm/s, Y at -12 mm/s".

Rather than a new execution path, JogController synthesises a stream of short
constant-velocity moves and feeds them to movement system 0 through exactly the
path a G1 takes (MovementState::raw -> GCodes::ReadMove ->
DDARing::AddStandardMove). Reusing that path buys three things that a bespoke
path would have had to reinvent:

 - lookahead blends consecutive chunks, so a steady stick gives steady motion
   and a change of direction produces a normal junction, not a stop-start;
 - DDA::InitStandardMove sets endSpeed = 0 until a following move exists, so the
   last move in the ring always plans to stop. A command stream that dies -
   cable pulled, host crashed, task starved - decelerates the machine under its
   normal limits instead of stopping it dead;
 - per-axis speed and acceleration limits, kinematics, bed compensation and tool
   offsets all keep working unchanged.

Safety:
 - the axis letters present define the whole velocity vector, so an axis that is
   not mentioned is stopped and a truncated command cannot leave one running;
 - watchdog: no M700 within R ms (default 250) zeroes the velocity;
 - each chunk goes through Kinematics::LimitPosition with initialCoords set, so
   the whole line is checked. An axis that reaches its limit stops while the
   others keep their commanded speed, because the chunk still takes one chunk
   time to execute;
 - speed is clamped to M203 and to 2.a.chunkTime, which is the ceiling
   InitStandardMove imposes anyway - commanding more would silently not be
   obeyed rather than going faster;
 - starting a jog requires the same axes homed that a G1 would, refuses while
   printing, and takes the movement lock once to start from a known standstill;
 - jogging is cancelled by M112/M999 and by anything that waits for standstill
   on movement system 0, which would otherwise never be reached.

Response to a stick movement is about D * P plus the ~50ms Move prepares ahead,
so ~150-200ms at the defaults. See Developer-documentation for the trade-offs.

Builds clean for Duet3_MB6HC (arm-gnu-toolchain 15.3.Rel1), no new warnings
under -Wall -Werror. Untested on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
The original 3 does not work, and the emulator says so precisely. A 20Hz M700
stream at 10mm/s with D3 collapses to 2.5mm/s at chunk boundaries and loses 7% of
the commanded distance (711 steps against 767 over the same interval). D4 is clean;
D5 is the default for margin, because a real machine has heaters, networking and
the SD card competing for the same main loop that tops the queue up.

The cause is not a blending failure, which is what the stutter looks like. It is
starvation: JogController::Spin adds at most one chunk per pass because it waits
for ms.segmentsLeft to fall back to zero, so the fill rate is a ping-pong between
the GCode and Move tasks. When the ring runs down to a single move, lookahead
correctly plans that move to stop at its end - the deceleration is right, there was
just nothing queued behind it.

This costs latency: about 300ms now rather than the 150-200ms the documentation
claimed, which was wrong on both counts. The docs now say so, and record that the
real lever is the one-chunk-per-pass handoff rather than the queue depth.
Four claims, all now measured under the emulator rather than argued: blending,
the 2.a.P speed ceiling, decelerate-on-loss-of-input, and per-axis limit clamping.

The ceiling turned out to be exactly right and linear in P - 20.08, 40.23 and 81.31
mm/s measured against 20, 40 and 80 predicted, all from a commanded 90mm/s. The
limit clamping holds too: with M208 X0:5 and M700 X10 Y10, X stops at 5.000mm while
Y continues to 10.000mm at an undisturbed 10.00mm/s, which is the subtlest of the
four and the one most likely to have been wrong.

Also notes what this does not prove: step timing comes from the TC model, and
nothing here checks that a real TMC5160 would follow the pulses.
Defaults move from D5/P50 to D2/P20, measured on the emulator by timing from
command injection to the step pins changing rate.

  D5 P50 -> 257ms    D3 P20 -> 126ms    D2 P20 -> 50ms
  D2 P15 ->  62ms    D2 P10 -> 127ms

D2/P20 is an optimum rather than a compromise. Below about 40ms of queued motion
the latency stops following D*P and gets worse, because Move wants roughly
MoveTiming::UsualMinimumPreparedTime queued before it will run moves - so
shortening the chunk or the queue past that point makes jogging less responsive,
not more. Doubling the command rate changed the result by 0.3ms, so the floor is in
the firmware rather than in how fast a host can send.

The earlier stutter that pushed the depth up to 5 was with 50ms chunks, where the
ring holds far more time and the producer has correspondingly longer to fall
behind; depth 2 with 20ms chunks measures clean over a 20Hz stream.

Going below ~50ms means reducing the preparation window in MoveTiming, which also
affects print moves and CAN expansion timing. Not done here.
The comment and docs claimed the sub-40ms floor was Move refusing to run until
MoveTiming::UsualMinimumPreparedTime was queued. Measured, that is false.

  hypothesis                              test                        result
  UsualMinimumPreparedTime is the floor   halved it, 50ms -> 25ms     50.3 -> 50.2 ms
  lookahead grace period                  M595 R0, and R0 P40         ~2 ms
  host command rate                       doubled to 10ms cadence     0.3 ms

Also filled in the D=2 curve, which is cleaner than the numbers previously
recorded: P=15 -> 44.6, P=20 -> 38.5, P=25 -> 67.2, P=30 -> 90.2 ms. Above the
optimum latency tracks the queued time D*P as a FIFO should.

Sizing each chunk to the requested speed (T >= v/2a, so a slow jog gets a short
chunk) was implemented and measured before being dropped: 3->15mm/s went
39.5 -> 75.4 ms and 1->3mm/s went 79 -> 245 ms. Not in the tree; recorded so it
is not tried again.

The floor is real and its cause is still unknown. No behaviour change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
RepRap::GetStatusIndex treats any live movement as busy. Jogging is continuous
by nature, so M700 pinned the status at busy for as long as the operator held
the stick - and DWC and AxisControl grey their controls out when the machine is
busy, including the controls that send the jog commands. Reported from a real
AxisControl session against the emulator: the jog panel disabled itself on every
speed update.

Jog motion no longer counts towards busy. The machine is manually controlled and
accepting commands, which is what idle means to a client.

Measured on the emulator, state.status sampled during a jog:
  before   idle busy busy busy
  after    idle idle idle idle
A normal G1 still reports busy, so only the jog case changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@T3P3

T3P3 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

please discuss this feature on the Duet3D forum first. We try not to clash with existing allocated gcodes as much as possible even if they are not allocated in RRF. M604:
https://reprap.org/wiki/G-code#.3D_M604:_Abort_serial_print

@meeloo

meeloo commented Aug 22, 2026

Copy link
Copy Markdown
Author

I have read the Duet3D CLA v2.0 and I hereby sign it

@meeloo

meeloo commented Aug 22, 2026

Copy link
Copy Markdown
Author

Thanks — raised on the forum as asked: https://forum.duet3d.com/topic/39396/making-one-axis-follow-another-in-real-time-dust-shoe

Same as the jogging one: happy to take whatever number (or sub-code) you would prefer and renumber. Leaving the PR open for the code, but the numbering decision belongs in that thread.

@meeloo
meeloo force-pushed the upstream/axis-following-m604 branch from 5c00937 to 539e6b7 Compare August 23, 2026 10:40
GenerateChunk advanced ms.currentUserPosition before the distance test and the
sub-threshold path returned without putting it back. SetMoveBufferDefaults seeds
initialCoords from raw.coords, so the rejected target became the next chunk's
baseline and the error compounded instead of recovering.

Commanding below MinChunkDistance/chunkTime (0.05 mm/s at the default P=20) made
M114 and the object model climb at the commanded speed while the machine stood
still. Measured with 40 x "M700 X0.01":

  before   X:3.447   Count 0 0 0 0      <- 3.4mm claimed, not one step taken
  after    X:0.000   Count 0 0 0 0

Fixed at both ends: the target is restored on the reject path, and ClampSpeeds
zeroes a speed too low to express in one chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
@meeloo
meeloo force-pushed the upstream/axis-following-m604 branch from 539e6b7 to 51e19e9 Compare August 23, 2026 10:42
meeloo and others added 5 commits August 23, 2026 13:08
JogController::Spin only stood aside for IsReallyPrintingOrResuming. A macro or a
tool change moves axes on its own account, so jogging carried on underneath it and
the two fought over the same movement system.

DoingFileMacro deliberately excludes daemon.g (GCodes.cpp:374), so a daemon on its
normal cycle does not chop the jog stream up - which is what made this safe to add.

Deliberately NOT included: WaitingForAcknowledgement. 'Jog to the workpiece corner,
then press OK' is a standard CNC setup pattern; the machine is stationary and the
operator is at the controls, so blocking it would remove a useful workflow for no
safety gain.

No regression: 25 x 'M700 X5' gives 536 step edges before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
A Z-independent dust shoe on a U axis is currently done in G-code: daemon.g polls Z
every 50ms and issues a G53 G1 U move when it has drifted more than 0.1mm. That is
inherently reactive - U can only start moving after Z already has, the correcting
move queues behind whatever motion is planned, and the daemon itself is restarted
every 5s.

M604 derives the follower where a move target is computed instead, so U and Z are
one coordinated move rather than two. Measured skew between the Z and U step trains
is 0.0000ms at the start, middle and end of a move - not low latency, none, because
the planner never sees them as separate axes.

  M604 A"U" B"Z" E1    engage; the relationship is captured from current positions
  M604 E0              disengage
  M604                 report

Applied in ToolOffsetTransform, which every move path passes through - straight
moves, arc segments and jog chunks alike. Verified on all three: a helical arc
tracks correctly, and a velocity jog on Z moves U step for step.

Deliberately matching the G-code semantics rather than inventing new ones:

 - scale defaults to -1. A shoe carried on the Z carriage has to move the opposite
   way to stay put, which is what daemon.g computes as targetU = U - deltaZ. I had
   this backwards until the real macros settled it;
 - the offset is captured when engaging, not supplied, which is what
   global.dustShoePrevZ exists for. Engaging means "hold the current separation";
 - refuses to engage an unhomed follower, matching the daemon check;
 - clamps to the axis M208 limits, so the shoe tracks down until it reaches its
   lower limit and rests there while Z carries on into the work.

Two things that silently produced no motion at all, both found by counting step
edges rather than reading coordinates back:

 - the follower has to be owned by the movement system. Without that the
   coordinate updates and M114 reports the new machine position while no steps are
   generated;
 - AllocateAxes sets the owned set rather than adding to it, so jogging dropped
   ownership of the follower. JogController now includes it.
Covers the migration from the daemon.g tracking loop, and the tool-change
interaction: because the rule is applied in machine coordinates after tool offsets,
tool length is handled automatically and the U half of G10 L1 Z{off} U{-off}
becomes redundant. Harmless, since a derived coordinate ignores its own tool
offset, but misleading to leave in place.

Also records why O should normally be omitted - engaging captures the current
separation, which is what global.dustShoePrevZ existed for.
move.axisFollower reports engaged, follower, leader, offset and scale. Without it a
UI would have to parse the text of an M604 report to know whether following is on,
which is not something a UI should have to do - and AxisControl needs exactly that
to show dust shoe state.

engaged is flagged live so it lands in the frequently-updated part of the model.
H1/H2 homing moves write ms.raw.coords directly and never pass through
ToolOffsetTransform, so the follower cannot track them. Left engaged, the machine
came out of homing still claiming a relationship homing had just broken.

Losing the datum on either axis now disengages following and says so; re-engaging
recaptures the offset, which is the intended workflow. M18 Z with U following Z:

  U follows Z as -1.000 * Z + 0.000, engaged
  M18 Z
  U follows Z as -1.000 * Z + 0.000, disengaged

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132JC621uq1434yhpzBNNgJ
@meeloo
meeloo force-pushed the upstream/axis-following-m604 branch from 51e19e9 to 205633f Compare August 23, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants