Merge upstream main: belt printing, texture displacement color mixing, 3MF component cycle checks, undo blocked during background jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-10-09 07:44:12 -04:00
co-authored by Claude Opus 5.5
227 changed files with 14506 additions and 1079 deletions
+476
View File
@@ -8,6 +8,7 @@
#include "libslic3r/Point.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Extruder.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/libslic3r.h"
#include <map>
#include <memory>
@@ -36,11 +37,52 @@
#include <boost/filesystem.hpp>
#include "test_helpers.hpp"
#include <cmath>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include <algorithm>
#include <limits>
#include "libslic3r/GCode/BeltKinematics.hpp"
#include "libslic3r/BeltTransform.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Arrange.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
TEST_CASE("Belt machine coordinates retain a non-45-degree slicing angle", "[GCodeWriter][belt]")
{
PrintConfig config;
config.belt_printer.value = true;
config.belt_slice_rotation.value = BeltRotationAxis::X;
config.belt_slice_rotation_angle.value = 30.;
config.gcode_remap_x.value = RemapAxis::PosX;
config.gcode_remap_y.value = RemapAxis::PosZ;
config.gcode_remap_z.value = RemapAxis::PosY;
GCodeWriter writer;
install_belt_kinematics(writer, config);
writer.set_axis_remap(int(config.gcode_remap_x.value),
int(config.gcode_remap_y.value),
int(config.gcode_remap_z.value));
// Start with a point in the unrotated model frame, then feed the writer the
// same rotated coordinate produced by the pre-slice mesh transform. The
// back-transform must recover the model point before the axis swap and
// machine-frame shear/scale are applied.
const Vec3d model(4., 10., 3.);
Transform3d forward = BeltTransformPipeline::build_forward_transform(config);
const Vec3d machine = writer.kinematics().to_machine(forward * model);
// The conventional X-tilt remap produces (x, z, y). At 30 degrees the
// gantry coordinate is z/sin(30) and belt travel is y + z*cot(30).
// The complementary tan/inv-cos formulas accidentally used by the unified
// transform are indistinguishable at 45 degrees, but fail this case.
REQUIRE_THAT(machine.x(), Catch::Matchers::WithinAbs(4., 1e-9));
REQUIRE_THAT(machine.y(), Catch::Matchers::WithinAbs(3. / std::sin(Geometry::deg2rad(30.)), 1e-9));
REQUIRE_THAT(machine.z(), Catch::Matchers::WithinAbs(10. + 3. / std::tan(Geometry::deg2rad(30.)), 1e-9));
}
// Arrange on a 500x500 bed, which keeps coordinates small while still covering large printers.
static void arrange_objects_on_test_bed(Model &model, const DynamicPrintConfig &config)
{
@@ -1230,6 +1272,440 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G
REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos);
}
// Regression test for the belt-printer "illegal gantry move at print start" bug.
//
// On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed
// by the first travel_to_xyz, whose NormalLift branch lifts in place via
// _travel_to_z(). On a normal printer _travel_to_z emits a Z-only move, but in
// belt mode Z is coupled to Y/X, so _travel_to_z re-emits the current m_pos
// through the belt shear. At print start (and after custom gcode)
// is_current_position_clear() is false and m_pos.xy is still the uninitialised
// origin (0,0), which shears into machine (X=bed_max, Y=layer_z) — a move far up
// the gantry, e.g. "G1 X95 Y168.19 Z237.857". The fix guards that lift on
// is_current_position_clear(), mirroring the SlopeLift branch.
SCENARIO("Belt: the first travel does not lift through the uninitialised origin", "[GCodeWriter][belt]")
{
GIVEN("A fresh belt-kinematics GCodeWriter configured for an X-tilt 45 degree belt") {
// Machine-frame + slicer->world back-transform config (X tilt, 45 deg).
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
belt_config.belt_frame_tilt_decouple.value = false;
belt_config.belt_frame_tilt_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
// travel_speed became per-extruder (ConfigOptionFloatsNullable) upstream.
writer.config.travel_speed.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
// A fresh writer has not established its planar position yet — this is the
// precondition that made the origin leak into the first move.
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a layer-change z-hop is pending and we travel to the first object point") {
// Defer a z-hop, exactly as a retract on layer change leaves it.
writer.lazy_lift(LiftType::NormalLift);
// First object point in slicing coordinates: a near-belt point (y ~= -z)
// so its transformed gantry Y is small (~1mm). The bogus origin lift, in
// contrast, would shear to machine Y ~= nominal_z.
const double nominal_z = 100.0;
std::string gcode = writer.travel_to_xyz(Vec3d(10.0, -(nominal_z - 1.0), nominal_z));
THEN("no emitted move flies up the gantry; machine Y stays near the part") {
double max_y = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_y](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(Y))
max_y = std::max(max_y, double(line.y()));
});
// The destination shears to machine Y ~= 1mm. The old origin-lift bug
// produced a separate move at machine Y ~= nominal_z (100mm), so any
// Y well above the part means the origin leaked into a move.
REQUIRE(max_y > 0.0); // the destination move was emitted and parsed
REQUIRE(max_y < 10.0); // ... and nothing flew up the gantry
}
}
}
}
// Regression test for the belt-printer "phantom extrusion line from Y=0" bug.
//
// GCodeProcessor::store_move_vertex pins a move's stored Z to the first-layer
// height while m_processing_start_custom_gcode is set (the start G-code "prepare"
// stage), because on a normal printer the toolhead Z there is not yet a real print
// height. On a belt printer that override is wrong: Z is written explicitly and the
// designed-view back-transform couples machine Z into the rendered model Y (the
// belt tilt mixes the height and belt-feed axes). Overriding it back-transforms the
// last prepare-stage move (the unretract right before the first extrusion) to
// model Y ~= 0, and libvgcode then draws a phantom extrusion segment from Y ~= 0 to
// the first real toolpath — rendered in the first extrusion role's color. The fix
// keeps the real Z for belt printers (gated on belt_tilt_angle). Here we assert the
// prepare-stage move keeps its real Z so it can no longer leak to Y ~= 0.
SCENARIO("Belt: start-gcode prepare-stage moves keep their real Z", "[GCode][belt]")
{
// Belt printers are non-Bambu, so the G-code uses the "compatible" reserved
// tags ("TYPE:" for the extrusion role). The processor selects the tag table
// from the static s_IsBBLPrinter flag, so mirror the belt-printer setting here
// (saved/restored so test ordering stays unaffected).
struct BBLPrinterGuard {
bool prev = GCodeProcessor::s_IsBBLPrinter;
BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; }
~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; }
} bbl_guard;
GIVEN("A belt G-code whose start sequence travels to a high machine Z before the first extrusion") {
// The leading "; belt_slice_rotation_angle = 45" header sets belt_tilt_angle
// (parsed before the body), enabling the belt code path. ;TYPE:Custom before
// any G1 turns on the prepare stage; ;TYPE:Outer wall turns it off, exactly
// as a sliced belt print is laid out.
const std::string gcode =
"; belt_slice_rotation_angle = 45\n"
"G90\n"
"G21\n"
"M83\n"
";TYPE:Custom\n"
"G1 E-1.5 F2100\n" // retract at the (0,0,0) origin
"G1 X45 Y0.3 Z50 F12000\n" // travel to the approach point (prepare stage)
"G1 E1.5 F1800\n" // unretract in place (prepare stage)
";TYPE:Outer wall\n"
"G1 X46 Y0.3 Z50 E0.05\n"; // first extrusion, same Z as the approach
GCodeProcessor processor;
processor.process_buffer(gcode);
const GCodeProcessorResult& result = processor.get_result();
THEN("the belt code path is active") {
REQUIRE_THAT(result.belt_tilt_angle, Catch::Matchers::WithinAbs(45.0, 1e-4));
}
WHEN("locating the first extrusion and the move that precedes it") {
size_t first_extrude = result.moves.size();
for (size_t i = 0; i < result.moves.size(); ++i)
if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; }
THEN("an extrusion and a preceding move exist") {
REQUIRE(first_extrude < result.moves.size());
REQUIRE(first_extrude > 0);
}
THEN("the preceding prepare-stage move shares the extrusion's real Z (no leak to Y=0)") {
const float extrude_z = result.moves[first_extrude].position.z();
const float prev_z = result.moves[first_extrude - 1].position.z();
// The first extrusion is at the real Z=50; before the fix the
// prepare-stage move's Z was pinned to the first-layer height
// (0 here) instead, which back-transforms to model Y ~= 0 and
// produces the phantom extrusion segment.
REQUIRE_THAT(extrude_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
REQUIRE_THAT(prev_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
}
}
}
}
// ---------------------------------------------------------------------------
// Regression tests for the two latent bugs the MachineKinematics refactor
// preserved deliberately and the follow-up commit fixed.
// ---------------------------------------------------------------------------
// Bug 1. _travel_to_z() emits full XYZ whenever the mapping must emit every
// axis, and it builds that point from m_pos. While the position is unknown,
// m_pos.xy is the uninitialised origin, which a reverse remap maps to the far
// corner of the bed. Belt kinematics guarded this; a Cartesian writer with an
// axis remap did not, and would command a rapid across the whole bed.
static void configure_lift_writer(GCodeWriter &writer)
{
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
writer.config.travel_speed.values = { 100.0 };
writer.config.travel_speed_z.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
}
// Largest X word in a chunk of emitted G-code, or lowest() if none.
static double max_emitted_x(const std::string &gcode)
{
double max_x = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_x](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(X))
max_x = std::max(max_x, double(line.x()));
});
return max_x;
}
static size_t count_g1(const std::string &gcode)
{
size_t n = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&n](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1")) ++n;
});
return n;
}
SCENARIO("Axis remap: no lift is commanded through the uninitialised origin", "[GCodeWriter][remap]")
{
// Reverse X: machine X = build_vol_max.x - logical X, so the uninitialised
// origin maps to the far edge of the bed and is unmistakable in the output.
const double bed_x = 250.0;
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("nothing is commanded at the image of the origin") {
// The destination maps to machine X = 250 - 10 = 240; the bogus
// origin lift would have mapped to machine X = 250.
REQUIRE(max_emitted_x(gcode) < bed_x - 1.0);
}
THEN("only the destination move is emitted") {
REQUIRE(count_g1(gcode) == 1);
}
}
}
GIVEN("the same writer once its position is known") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
writer.travel_to_xyz(Vec3d(20.0, 20.0, 5.0));
REQUIRE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel again") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(30.0, 30.0, 5.0));
THEN("the separate lift move is still emitted") {
// Suppression must be pinned to the unknown position, not to the
// presence of a remap.
REQUIRE(count_g1(gcode) == 2);
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
REQUIRE_FALSE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("behaviour is unchanged: the lift is still emitted") {
// Three moves, not two: with no remap and an unknown position the
// destination is emitted as a separate XY move followed by its own
// Z move, on top of the lift. That split is the pre-existing
// identity-mapping path and must not change.
REQUIRE(count_g1(gcode) == 3);
}
}
}
}
SCENARIO("Axis remap: eager_lift does not lift, or record a lift, at an unknown position",
"[GCodeWriter][remap]")
{
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(250.0, 250.0, 250.0));
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("no move is emitted") {
REQUIRE(lift.empty());
}
THEN("no lift is recorded, so unlift does not descend from it") {
// If m_lifted had been set while nothing was commanded, unlift()
// would emit a descent from a height the machine never reached.
REQUIRE(writer.unlift().empty());
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("behaviour is unchanged: the lift is emitted and can be undone") {
REQUIRE_FALSE(lift.empty());
REQUIRE_FALSE(writer.unlift().empty());
}
}
}
}
// Bug 2. extrude_arc_to_xy() emits G2/G3 with logical X/Y and I/J and never
// consulted the mapping. An arc is only representable when logical X and Y reach
// the machine unchanged -- which is a narrower question than "is the remap the
// identity", because a mapping that only touches Z leaves every emitted word alone.
SCENARIO("Arc support is decided by whether the mapping leaves X and Y alone", "[GCodeWriter][remap]")
{
GIVEN("a Cartesian writer") {
GCodeWriter writer;
THEN("the identity mapping supports arcs") {
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only negation still supports arcs") {
// (+X, +Y, -Z): non-identity, but X, Y, I and J are all untouched.
writer.set_axis_remap(0, 1, 5);
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only reversal still supports arcs") {
writer.set_axis_remap(0, 1, 8);
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("swapping X and Y does not support arcs") {
writer.set_axis_remap(1, 0, 2);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
THEN("the X-tilt style (x, z, y) remap does not support arcs") {
writer.set_axis_remap(0, 2, 1);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
GIVEN("a belt writer") {
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
THEN("arcs are never supported, because the frame shears") {
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
}
SCENARIO("An unrepresentable arc degrades to its chord rather than emitting a wrong G2/G3",
"[GCodeWriter][remap]")
{
auto emitted_commands = [](const std::string &gcode) {
std::vector<std::string> cmds;
GCodeReader reader;
reader.parse_buffer(gcode, [&cmds](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (! line.cmd().empty()) cmds.emplace_back(line.cmd());
});
return cmds;
};
GIVEN("an identity-mapping writer") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("it is still a G3") {
const auto cmds = emitted_commands(gcode);
REQUIRE(cmds.size() == 1);
REQUIRE(cmds.front() == "G3");
}
}
}
GIVEN("a writer whose mapping swaps X and Y") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("no arc is emitted; it is approximated with linear moves") {
const auto cmds = emitted_commands(gcode);
REQUIRE(! cmds.empty());
for (const auto &c : cmds)
REQUIRE(c == "G1");
}
}
}
// The first version of this test used dE = 0 with force_no_extrusion, which
// hid a real bug: the capability check sat AFTER filament()->extrude(dE), so
// the fallback into extrude_to_xy() advanced E twice. Extrusion accounting has
// to be asserted with a positive dE.
GIVEN("a writer whose mapping cannot express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("exactly dE is accounted for, not twice dE") {
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
THEN("no G2/G3 survives") {
REQUIRE(gcode.find("G2") == std::string::npos);
REQUIRE(gcode.find("G3") == std::string::npos);
}
}
}
GIVEN("a writer whose mapping CAN express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("it is still a single arc and accounts for dE once") {
REQUIRE(emitted_commands(gcode).size() == 1);
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
}
}
}
TEST_CASE("Percent accelerations resolve against the option they are a percentage of", "[GCodeWriter]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();