Add axis following (M604) - #1265
Open
meeloo wants to merge 12 commits into
Open
Conversation
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
|
All contributors have signed the CLA ✍️ ✅ |
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: |
Author
|
I have read the Duet3D CLA v2.0 and I hereby sign it |
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
force-pushed
the
upstream/axis-following-m604
branch
from
August 23, 2026 10:40
5c00937 to
539e6b7
Compare
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
force-pushed
the
upstream/axis-following-m604
branch
from
August 23, 2026 10:42
539e6b7 to
51e19e9
Compare
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
force-pushed
the
upstream/axis-following-m604
branch
from
August 23, 2026 11:10
51e19e9 to
205633f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Ais the driven axis,Bthe tracked one,Sthe scale (default -1, since a follower carried on the leader must move the opposite way to stay put),Oan offset,Eengages or disengages. The rule isfollower = S * leader + Oin machine coordinates, clamped to the follower'sM208limits. No parameters reports state.Engaging without
Ocaptures 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::Applyruns at the end ofToolOffsetTransform, 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:
AxisFollowertouchesJogControllerso the two cannot be separated cleanly. Review the last three commits, or merge M700 first and this will shrink to them.