Skip to content

ChangeAtZ - Fix #8574 and #8886#21316

Open
Wes Hanney (novamxd) wants to merge 2 commits into
Ultimaker:mainfrom
novamxd:master
Open

ChangeAtZ - Fix #8574 and #8886#21316
Wes Hanney (novamxd) wants to merge 2 commits into
Ultimaker:mainfrom
novamxd:master

Conversation

@novamxd
Copy link
Copy Markdown
Contributor

@novamxd Wes Hanney (novamxd) commented Jan 20, 2026

Description

Type of change

  • [ X ] Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

I pulled the file given by GhostKeeper (here) and validated that it properly targets the expected heights. Also tested ranges with this file.

I also pulled the 25x25 Cube given by AbeFM (here) and that the fan speed is properly switched back to 30% after the desired layer.

Test Configuration:

  • Operating System: Windows 11. Cura 5.11.

Checklist:

  • [ Probably? ] My code follows the style guidelines of this project as described in UltiMaker Meta and Cura QML best practices
  • [ ✅ ] I have read the Contribution guide
  • [ ✅ ] I have commented my code, particularly in hard-to-understand areas
  • [ ✅ ] I have uploaded any files required to test this change

25x25Cube (1).zip
change_at_z_temperature (1).zip

…not properly apply (or change back) certain values. Z targeting now works by determining the target layer based on the minimum Z for a given layer. Added support for layer and Z ranges. Added support for reading layer height from project settings.

Kept backwards compatibility with older configs of ChangeAtZ.
@github-actions github-actions Bot added the PR: Community Contribution 👑 Community Contribution PR's label Jan 20, 2026
@novamxd Wes Hanney (novamxd) changed the title Fixed issues #8574 and #8886 where ChangeAtZ would not properly apply… ChangeAtZ - Fix #8574 and #8886 Jan 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jan 20, 2026

Test Results

24 592 tests  ±0   24 590 ✅ ±0   51s ⏱️ -1s
     1 suites ±0        2 💤 ±0 
     1 files   ±0        0 ❌ ±0 

Results for commit fc208dd. ± Comparison against base commit 2e1a12c.

♻️ This comment has been updated with latest results.

@novamxd Wes Hanney (novamxd) marked this pull request as ready for review January 20, 2026 04:42
@GregValiant
Copy link
Copy Markdown
Collaborator

Wes Hanney (@novamxd) could you take a look at the current "TweakAtZ". The thought was to obsolete "Change At Z" in due time. It was left in Cura as project files might include it.
I have a PR in for a change to TweakAtZ that enables support for One-at-a-Time print sequence. See #21209 .

Tweak at Z currently has "Start/End layers" and "Start/End heights. If you could see if your changes can apply to Tweak At Z it would be helpful.

@novamxd
Copy link
Copy Markdown
Contributor Author

Wes Hanney (novamxd) commented Jan 27, 2026

Wes Hanney (@novamxd) could you take a look at the current "TweakAtZ". The thought was to obsolete "Change At Z" in due time. It was left in Cura as project files might include it. I have a PR in for a change to TweakAtZ that enables support for One-at-a-Time print sequence. See #21209 .

Tweak at Z currently has "Start/End layers" and "Start/End heights. If you could see if your changes can apply to Tweak At Z it would be helpful.

Hey GregValiant (@GregValiant)

I already saw TweakAtZ. Personally I'm disappointed it exists and is the reason I decided to push my changes through.

There is and was nothing preventing you from updating the existing ChangeAtZ plugin to support the new features without breaking backwards compatibility, and yet you chose otherwise. Now customers of Cura are left with old projects that won't work right requiring a migration and any existing tutorials or documentation now invalid.

This was a terrible decision, in my opinion.

If anything I will slowly port the TweakAtZ functions missing from ChangeAtZ into ChangeAtZ, making TweakAtZ no longer required.

I hope this clarifies!

@GregValiant
Copy link
Copy Markdown
Collaborator

I started out updating Change at Z but when I got finished it was very different. Tweak at Z has the same (I think) functionality, but it is 700 lines of code shorter and it is WAY faster.

I thought one of the problems with Change At Z was the way speed changes were handled. Using M220 affects any speed. I went a different way and adjusted the individual speed "F" parameters in the Gcode. That allows Print, Travel, Retract/Prime, and Z-hop speeds to be handled separately. The fan commands were no longer needed as "Advanced Cooling Fan Control" covered that.
The addition of the "Start and End" Layer and Height (rather than "This Layer" or "All Layers") was also a large change.

The settings for Change At Z just didn't fit well anymore and a project that called Change at Z could well have had "unexpected consequences".

@novamxd
Copy link
Copy Markdown
Contributor Author

Wes Hanney (novamxd) commented Feb 8, 2026

Hey GregValiant (@GregValiant),

I started out updating Change at Z but when I got finished it was very different.

So? I did a huge refactor when I first picked it up and after. So long as the existing functionality is undisturbed that's 100% fine. In fact so long as the customer experience is undisturbed, go ham.

but it is 700 lines of code shorter and it is WAY faster.

The speed increase is only useful if the functionality wasn't impaired. Function size at the end of the day doesn't matter much so long as it's easy to read.

I thought one of the problems with Change At Z was the way speed changes were handled. Using M220 affects any speed.

So? Make a toggle. As it is "speed" in ChangeAtZ highly depends on what you're modifying. Retract speed, for example, works differently depending on whether firmware retractions are enabled or not. If it's not firmware retractions it's a simple multiplication like your plugin. Also the current ChangeAtZ plugin solved a problem: overlapping multiplication. I don't see much of a means of memory in your current plugin, but I haven't reviewed it deeply. If your plugin has no memory component it means subsequent TweakAtZ will compound, causing unexpected changes.

That allows Print, Travel, Retract/Prime, and Z-hop speeds to be handled separately.

So? I can add that to the current ChangeAtZ plugin, and probably will.

The fan commands were no longer needed as "Advanced Cooling Fan Control" covered that.

I mean that only applies for new projects and those with newer versions of Cura. For new projects or folks who want to continue using ChangeAtZ it can remain, plus any existing documentation. For those who don't, they can use the baked in function.

The addition of the "Start and End" Layer and Height (rather than "This Layer" or "All Layers") was also a large change.

So? I added that pretty easily to ChangeAtZ.

The settings for Change At Z just didn't fit well anymore and a project that called Change at Z could well have had "unexpected consequences".

I mean you more or less ported them to your TweakAtZ.

Based on your responses there's nothing here that really convinces me that creating a new plugin was the right call, in fact I'd say it was a bad one. Like I said, I'll continue to support ChangeAtZ and I'll work to bring any missing functions to it.

Whether you want to keep yours is up to you.

@novamxd
Copy link
Copy Markdown
Contributor Author

Ghostkeeper Do you know when this might get merged in?

@GregValiant
Copy link
Copy Markdown
Collaborator

GregValiant (GregValiant) commented Mar 29, 2026

Wes Hanney (@novamxd) - Ghostkeeper left UltiMaker years ago. My impression is that he no longer cares to have anything to do with UM Cura.
HellAholic may have an opinion on this PR.

@novamxd
Copy link
Copy Markdown
Contributor Author

GregValiant (@GregValiant) Good to know!

@HellAholic
Copy link
Copy Markdown
Contributor

GregValiant (@GregValiant) Don't think we're gonna get to this soon. Currently we're both busy with fixes and a new release, so the earliest would be end of April.

I also see some not great things like creating a temp directory every time cura opens due to the Module-level side effects of:

temp_dir = mkdtemp("", "ChangeAtZ")
os.makedirs(temp_dir, exist_ok=True)

Logger.info("ChangeAtZ dir: %s", temp_dir)

executions = 0

Or the caz_retractstyle being hidden from UI and locked to the default value by "enabled": "false"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the ChangeAtZ post-processing script to improve Z/layer targeting reliability (including ranges) and ensure values are correctly applied/restored across layers, addressing issues #8574 and #8886.

Changes:

  • Refactors targeting to determine the active layer based on the layer’s minimum Z, with support for start/end layer and Z ranges.
  • Refactors GCode parsing/manipulation via a richer GCodeCommand model and rewrites the modification pipeline (getTargetGCode / getLineChanges / getOriginalGCode).
  • Reads layer height from project settings and updates UI labels/descriptions for settings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# update our layer number if applicable
self.processLayerNumber(line)
for current_section in sections:
Logger.info("---------------------------------------")
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is very verbose Logger.info output in the hot path (per section/layer and on most value changes). This can flood logs during slicing and slow down processing. Consider switching these to Logger.debug (or gating behind a dedicated debug flag) and keeping info for high-level events only.

Suggested change
Logger.info("---------------------------------------")
Logger.debug("---------------------------------------")

Copilot uses AI. Check for mistakes.
Comment on lines 572 to 574
# break into the individual components
command_pieces = line.strip().split(" ")
command_pieces = command.split(" ")

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCodeCommand.getFromLine uses command.split(" "), which produces empty tokens for multiple spaces. parseArguments() then does component[0] and will crash on empty strings. Use command.split() (no separator) or filter out empty components before storing them.

Copilot uses AI. Check for mistakes.
Comment on lines +753 to 760

if value is None:
self.__arguments.pop(key, None)
return

self.__arguments[key] = value


Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCodeCommand.setArgument updates __arguments but never updates __arguments_order. If you add a new argument that wasn’t present in the original command, __str__ won’t emit it, so modifications may silently be dropped. Ensure setArgument adds the key to __arguments_order when introducing a new argument (and removes it when deleting).

Suggested change
if value is None:
self.__arguments.pop(key, None)
return
self.__arguments[key] = value
# Track whether this key already existed before the update
key_already_exists = key in self.__arguments
if value is None:
# Remove from arguments mapping
self.__arguments.pop(key, None)
# And also remove from the order list to keep both in sync
if hasattr(self, "_GCodeCommand__arguments_order"):
try:
while key in self.__arguments_order:
self.__arguments_order.remove(key)
except ValueError:
# If not present, nothing to do
pass
return
# Set or update the argument value
self.__arguments[key] = value
# If this is a new key, append it to the order list so it will be emitted
if not key_already_exists and hasattr(self, "_GCodeCommand__arguments_order"):
if key not in self.__arguments_order:
self.__arguments_order.append(key)

Copilot uses AI. Check for mistakes.
Comment on lines 497 to +501
# Contains any arguments passed to the command. The key is the argument name, the value is the value of the argument.
arguments = {}
__arguments: dict[str, any] = {}

# Contains the components of the command broken into pieces
components = []
__components: list[str] = []
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotations use any (e.g. dict[str, any]), but any is the built-in function, not a typing type. This makes the annotations misleading and can break type-checking tooling. Use typing.Any (and import it) instead.

Copilot uses AI. Check for mistakes.
Comment on lines +1297 to +1303
if modified_command.command == "M221":
extruder = modified_command.getArgumentAsInt("T")

# handle retract feed rate
new_line = self.processRetractFeedRate(extrude_length, feed_rate, new_line, x_coord, y_coord, z_coord)
if extruder is None:
return

# handle print speed adjustments
if extrude_length is not None: # Only for extrusion moves.
new_line = self.processPrintSpeed(feed_rate, new_line)
flow_rate = None
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processTargetValues for M221 returns immediately when T is missing. Cura often emits M221 S... without a tool index (global flow), and the script also supports a global flowrate setting. Consider handling T == None by applying the global flowrate target so later M221 lines don’t override the intended value.

Copilot uses AI. Check for mistakes.
caz_instance.targetLayer = self.getIntSettingByKey("b_targetL", None)
caz_instance.targetZ = self.getFloatSettingByKey("b_targetZ", None)
caz_instance.targetLayerStart = self.getIntSettingByKey("b_targetL", None)
caz_instance.targetLayerEnd = self.getFloatSettingByKey("b_targetLEnd", -1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b_targetLEnd is declared as an int setting, but it’s read using getFloatSettingByKey. This makes targetLayerEnd a float and can cause incorrect comparisons in isInsideTargetArea (and is inconsistent with the setting schema). Read it with getIntSettingByKey and keep the type consistent (int | None).

Suggested change
caz_instance.targetLayerEnd = self.getFloatSettingByKey("b_targetLEnd", -1)
caz_instance.targetLayerEnd = self.getIntSettingByKey("b_targetLEnd", -1)

Copilot uses AI. Check for mistakes.
},
"default_value": "linear",
"enabled": "caz_change_retract"
"enabled": "false"
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caz_retractstyle UI setting is now permanently disabled ("enabled": "false"), while linearRetraction is derived from the machine setting instead. This makes the option non-functional and can break backward compatibility for configs that relied on the script-level retract style. Either remove the setting entirely (and migrate old configs), or keep it enabled and reconcile precedence between machine vs script settings.

Suggested change
"enabled": "false"
"enabled": "caz_change_retract"

Copilot uses AI. Check for mistakes.

pieces.append(arg + str(value))

return " ".join(pieces) + (";" + self.comment if self.hasComment() else "")
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__str__ always prefixes ; before self.comment, but getFromLine currently stores the comment starting with ; (e.g. comment = line[comment_index:]). If a command is ever stringified with a comment, this will produce ;;.... Consider storing comments without the leading semicolon or changing __str__ to not double-prefix.

Suggested change
return " ".join(pieces) + (";" + self.comment if self.hasComment() else "")
return " ".join(pieces) + (self.comment if self.hasComment() else "")

Copilot uses AI. Check for mistakes.
if extruder is None:
self.lastValues["flowrate"] = temperature
self.setLastValue("flowrate", temperature)
elif extruder == 1:
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flowrate tracking for M221 has an incorrect extruder mapping: both branches check extruder == 1, so flowrateTwo is never recorded and flowrateOne is recorded for T1. This will prevent correct restoration/overrides for per-extruder flow settings. Map T0->flowrateOne and T1->flowrateTwo.

Suggested change
elif extruder == 1:
elif extruder == 0:

Copilot uses AI. Check for mistakes.
Comment on lines 1176 to +1179
if self.applyToSingleLayer:
return self.currentLayer == self.targetLayer
return self.minZ == self.targetZStart
else:
return self.currentLayer >= self.targetLayer
return self.minZ >= self.targetZStart and (self.targetZEnd == -1 or self.minZ < self.targetZEnd)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Z single-layer targeting uses self.minZ == self.targetZStart. Exact float equality is brittle (Z heights are often formatted/rounded), so this can miss the intended layer. Also, the end-range check uses < self.targetZEnd even though the UI text says the end height is included. Consider using a tolerance (or layerHeight-based comparison) and making the end comparison inclusive (<=) to match the setting description.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: Community Contribution 👑 Community Contribution PR's

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants