feat(imex): firmware-managed zones — center slice + ghost suppression

Adds the `imex_firmware_managed_zones` printer-config key (default off) for
IDEX/IQEX printers whose firmware applies its own copy/mirror offsets in
non-primary modes (e.g. RepRapFirmware IDEX duplication mode, Flashforge
Creator Pro 2/3 Pro). For these printers the slicer needs to emit a single
centered slice at bed origin and let the firmware fan toolheads out from there;
the previous slicer-managed iMEX rendering would draw a print at the primary
zone's world position (off-bed for the firmware-fan-out paradigm).

When the flag is on and the active mode is non-primary, the slicer subtracts
the primary zone's plate-local center from the gcode emission frame. The
writer offset is augmented but the gcode-processor offset stays at plate_origin
so the gcode-preview visualizer renders the centered slice at the bed center
rather than at the prepare-view zone placement. translate_to_print_space is
augmented too so first_layer_print_min/max placeholders (consumed by user
start_gcode like Felix's M118 header) reflect the centered frame.

Slice handoff lives in PartPlate::refresh_imex_slice_offset, called from both
update_slice_context (plate switch) and Plater::priv::update_background_process
(every-slice path — reslice() goes through here with switch_print=false so the
plate-switch hook alone wouldn't fire on mode toggle).

calc_imex_ghosts early-returns in firmware-managed mode: the existing
imex_head_transform math places ghosts at primary_zone_center + gantry_offset
(slicer-managed semantics), which renders off-bed when the toolpath is being
emitted in a centered frame. Proper firmware-managed ghost rendering (showing
where copies/mirrors will actually print after firmware fan-out) is deferred.

When the flag is off, all the new code paths reduce to no-ops byte-identical to
prior behavior. Layer 1 unit tests in test_imex_helpers cover every gating path
of compute_imex_slice_offset; full ctest suite passes (247/247).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-05-13 00:55:45 -04:00
co-authored by Claude Opus 4.7
parent 493a9e96d2
commit 48c603faa5
13 changed files with 199 additions and 4 deletions
+9
View File
@@ -216,6 +216,15 @@ public:
void export_layer_filaments(GCodeProcessorResult* result);
//BBS: set offset for gcode writer
void set_gcode_offset(double x, double y) { m_writer.set_xy_offset(x, y); m_processor.set_xy_offset(x, y);}
// IMEX firmware-managed slice: writer offset is augmented by the IMEX shift so the
// emitted gcode is centered at bed origin (firmware fans copies/mirrors out from there).
// Processor offset stays at plate_origin only so the gcode-preview visualizer renders
// the centered toolpath at bed center, not at the prepare-view zone placement. When
// imex_x/imex_y are zero this is byte-identical to set_gcode_offset(x, y).
void set_gcode_offset_with_imex_shift(double x, double y, double imex_x, double imex_y) {
m_writer.set_xy_offset(x + imex_x, y + imex_y);
m_processor.set_xy_offset(x, y);
}
// Exported for the helper classes (OozePrevention, Wipe) and for the Perl binding for unit tests.
const Vec2d& origin() const { return m_origin; }
+11
View File
@@ -388,4 +388,15 @@ Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role,
return Transform3d::Identity();
}
Vec2d compute_imex_slice_offset(bool firmware_managed,
const std::string& parallel_mode,
const std::optional<BoundingBoxf>& primary_zone_box)
{
if (!firmware_managed) return Vec2d::Zero();
if (parallel_mode.empty()) return Vec2d::Zero();
if (parallel_mode == kImexPrimaryMode) return Vec2d::Zero();
if (!primary_zone_box.has_value()) return Vec2d::Zero();
return primary_zone_box->center();
}
} // namespace Slic3r
+11
View File
@@ -1,11 +1,13 @@
#pragma once
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include <Eigen/Geometry>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Point.hpp"
@@ -289,4 +291,13 @@ Transform3d imex_head_transform(int primary, int target, ImexRole role,
const Vec2d& gantry_offset,
const Vec2d& primary_zone_center = Vec2d::Zero());
// Slice-time XY shift for printers that delegate copy/mirror placement to firmware.
// When `imex_firmware_managed_zones` is on AND the active mode is non-primary, returns
// the primary zone's plate-local center so gcode emission can subtract it (yielding a
// centered slice the firmware can fan out). Returns Vec2d::Zero() in every other case:
// flag off, primary/empty mode, or no zone box (IMEX disabled, primary-only, etc.).
Vec2d compute_imex_slice_offset(bool firmware_managed,
const std::string& parallel_mode,
const std::optional<BoundingBoxf>& primary_zone_box);
} // namespace Slic3r
+2 -1
View File
@@ -1354,7 +1354,8 @@ static std::vector<std::string> s_Preset_printer_options {
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
"bed_temperature_formula", "nozzle_flush_dataset",
// IDEX/IQEX (independent X extruder) — printer capability and user-defined modes
"is_imex", "imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout",
"is_imex", "imex_firmware_managed_zones",
"imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout",
"imex_nozzle_clearance_x", "imex_nozzle_clearance_y", "imex_carriage_margin", "imex_viz_theme",
"imex_mode_names", "imex_mode_active_tools", "imex_mode_gcodes"
};
+14 -3
View File
@@ -2551,7 +2551,12 @@ std::string Print::export_gcode(const std::string& path_template, GCodeProcessor
GCode gcode;
//BBS: compute plate offset for gcode-generator
const Vec3d origin = this->get_plate_origin();
gcode.set_gcode_offset(origin(0), origin(1));
// IMEX firmware-managed zones: writer offset gets the IMEX shift so emitted gcode is
// centered at bed origin; processor offset stays at plate_origin so the gcode-preview
// visualizer renders the centered slice at the bed center rather than the prepare-view
// zone placement. Both arms reduce to set_gcode_offset() semantics when shift is zero.
const Vec2d imex_off = this->get_imex_slice_offset();
gcode.set_gcode_offset_with_imex_shift(origin(0), origin(1), imex_off.x(), imex_off.y());
gcode.do_export(this, path.c_str(), result, thumbnail_cb);
gcode.export_layer_filaments(result);
//BBS
@@ -2820,13 +2825,19 @@ Points Print::first_layer_wipe_tower_corners(bool check_wipe_tower_existance) co
}
//SoftFever
// "Print space" is the frame the emitted gcode uses. IMEX firmware-managed mode
// shifts that frame by `m_imex_slice_offset` (the primary zone center in plate-local
// coords) so the centered slice can be fanned out by firmware. Offset is zero in
// every other case → identical behavior for non-firmware-managed printers.
Vec2d Print::translate_to_print_space(const Vec2d &point) const {
//const BoundingBoxf bed_bbox(config().printable_area.values);
return Vec2d(point(0) - m_origin(0), point(1) - m_origin(1));
return Vec2d(point(0) - m_origin(0) - m_imex_slice_offset.x(),
point(1) - m_origin(1) - m_imex_slice_offset.y());
}
Vec2d Print::translate_to_print_space(const Point &point) const {
return Vec2d(unscaled(point.x()) - m_origin(0), unscaled(point.y()) - m_origin(1));
return Vec2d(unscaled(point.x()) - m_origin(0) - m_imex_slice_offset.x(),
unscaled(point.y()) - m_origin(1) - m_imex_slice_offset.y());
}
FilamentTempType Print::get_filament_temp_type(const std::string& filament_type)
+8
View File
@@ -1040,6 +1040,12 @@ public:
//BBS: plate's origin related functions
void set_plate_origin(Vec3d origin) { m_origin = origin; }
const Vec3d get_plate_origin() const { return m_origin; }
// IMEX firmware-managed zones: additional XY shift applied at gcode emission so the
// primary zone's contents land at bed origin (0,0). Set by the GUI/PartPlate slice
// handoff when `imex_firmware_managed_zones` is on AND the active mode is non-primary.
// Vec2d::Zero() in every other case → byte-identical gcode output.
void set_imex_slice_offset(const Vec2d& o) { m_imex_slice_offset = o; }
Vec2d get_imex_slice_offset() const { return m_imex_slice_offset; }
//BBS: export gcode from previous gcode file from 3mf
void set_gcode_file_ready();
void set_gcode_file_invalidated();
@@ -1166,6 +1172,8 @@ private:
//BBS: plate's origin
Vec3d m_origin;
// IMEX firmware-managed slice offset (plate-local primary-zone center, or zero).
Vec2d m_imex_slice_offset { Vec2d::Zero() };
//BBS: modified_count
int m_modified_count {0};
//BBS
+15
View File
@@ -5620,6 +5620,21 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false));
// Firmware-managed zones: slice the primary zone's contents at bed origin (0,0)
// so a center-origin printer firmware can apply its own copy/mirror offsets to
// each toolhead. The slicer's zone overlay, placement check, and mode signaling
// are unchanged; only the gcode emission frame shifts.
def = this->add("imex_firmware_managed_zones", coBool);
def->label = L("Firmware-managed zones (center-origin slice)");
def->tooltip = L("When enabled, the slicer emits g-code with the primary zone's "
"contents centered on bed origin (0,0). The printer firmware is "
"responsible for placing copies/mirrors at each physical zone "
"position. Enable only on printers whose firmware applies its own "
"zone offsets in copy/mirror mode (e.g. RepRapFirmware IDEX "
"duplication mode). Has no effect in Primary mode.");
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false));
def = this->add("imex_gantry_count", coInt);
def->label = L("Gantry Count");
def->tooltip = L("Number of independent Y-axis gantries. 1 for IDEX/single-rail IQEX. 2 for dual-gantry systems (divides bed into rows).");
+1
View File
@@ -1370,6 +1370,7 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionBool, manual_filament_change))
// IDEX/IQEX (independent X extruder) — parallel printing support for IDEX/IQEX printers
((ConfigOptionBool, is_imex))
((ConfigOptionBool, imex_firmware_managed_zones))
((ConfigOptionInt, imex_gantry_count))
((ConfigOptionInt, imex_tools_per_gantry))
((ConfigOptionFloat, imex_nozzle_clearance_x))
+42
View File
@@ -1089,6 +1089,17 @@ void PartPlate::calc_imex_ghosts()
int primary_phys = -1;
if (!resolve_active_mode_tools(active_tools_str, primary_phys)) return;
// IMEX firmware-managed zones: the slicer emits a centered single-half slice and the
// firmware fans copies/mirrors out from there — placement is not slicer-authoritative.
// Rendering secondary-tool ghosts via imex_head_transform (which assumes slicer-managed
// placement at primary_zone_center + gantry_offset) would draw them at positions the
// firmware doesn't honor (e.g. off-bed once the centered slice is in play). Suppress
// ghost generation entirely so we don't lie about something the slicer doesn't own.
if (auto* fw_opt = wxGetApp().preset_bundle->printers.get_edited_preset()
.config.option<ConfigOptionBool>("imex_firmware_managed_zones");
fw_opt && fw_opt->value)
return;
// Zone centers are the basis for ghost placement; make sure they exist before
// we read them. render_imex_zones already ensures this, but ghost rebuild can
// also be driven from mode/preset invalidation paths that don't touch zones.
@@ -4507,6 +4518,35 @@ void PartPlate::update_slice_result_valid_state(bool valid)
}
}
// IMEX firmware-managed zones: compute the plate-local primary-zone center and push it
// to m_print as the slice-time XY shift. Vec2d::Zero() in every non-firmware-managed
// path (flag off, primary/empty mode, empty zone box) → byte-identical gcode output for
// unaffected printers. Defensive accessors tolerate option() returning nullptr in case
// the preset bundle is reached during early Plater construction.
// Called both from update_slice_context (plate switch path) and from
// Plater::priv::update_background_process (every-slice path) so reslice with mode toggled
// without plate change picks up the right shift.
void PartPlate::refresh_imex_slice_offset()
{
Vec2d imex_off = Vec2d::Zero();
if (auto* app = &wxGetApp(); app && app->preset_bundle && m_print) {
const DynamicPrintConfig& printer_cfg = app->preset_bundle->printers.get_edited_preset().config;
auto* fw_opt = printer_cfg.option<ConfigOptionBool>("imex_firmware_managed_zones");
if (fw_opt && fw_opt->value) {
std::string active_mode = get_imex_mode();
if (active_mode == kImexPrimaryMode) {
const DynamicPrintConfig& process_cfg = app->preset_bundle->prints.get_edited_preset().config;
if (auto* mo = process_cfg.option<ConfigOptionString>("imex_parallel_mode"))
if (!mo->value.empty()) active_mode = mo->value;
}
ensure_imex_zones();
imex_off = compute_imex_slice_offset(true, active_mode, m_imex_primary_zone_box);
}
}
if (m_print)
m_print->set_imex_slice_offset(imex_off);
}
//update current slice context into backgroud slicing process
void PartPlate::update_slice_context(BackgroundSlicingProcess & process)
{
@@ -4520,6 +4560,8 @@ void PartPlate::update_slice_context(BackgroundSlicingProcess & process)
wxQueueEvent(m_plater, event);
};
refresh_imex_slice_offset();
process.set_fff_print(m_print);
process.set_gcode_result(m_gcode_result);
process.select_technology(this->printer_technology);
+4
View File
@@ -522,6 +522,10 @@ public:
// Returns the primary-zone bounding box in mm when an IDEX/IQEX parallel mode is active.
// Empty (nullopt) when IDEX/IQEX is off or the mode is "primary" (full-bed).
std::optional<BoundingBoxf> imex_primary_zone() { ensure_imex_zones(); return m_imex_primary_zone_box; }
// Compute the IMEX firmware-managed slice offset and push it to m_print. Safe to call
// every slice. Sets Vec2d::Zero() when the printer flag is off, the mode is primary, or
// the preset bundle isn't ready yet. Must be called from the GUI thread.
void refresh_imex_slice_offset();
// Returns carriage collision strips (mm) for the current IDEX/IQEX mode.
// Always call imex_primary_zone() first to ensure the cache is warm.
const std::vector<BoundingBoxf3>& imex_collision_zones() const { return m_imex_collision_zones; }
+7
View File
@@ -7933,6 +7933,13 @@ unsigned int Plater::priv::update_background_process(bool force_validation, bool
else
invalidated = background_process.apply(this->model, preset_bundle->full_config(false));
// IMEX firmware-managed zones: refresh the per-slice XY shift on the print backend.
// Plater::reslice() calls update_background_process with switch_print=false, which skips
// update_slice_context_to_current_plate — so the offset would otherwise stay stale across
// mode changes. Pushing it here guarantees every slice picks up the current mode + zone.
if (auto* cur_plate = this->partplate_list.get_curr_plate())
cur_plate->refresh_imex_slice_offset();
if ((invalidated == Print::APPLY_STATUS_CHANGED) || (invalidated == Print::APPLY_STATUS_INVALIDATED))
// BBS: add only gcode mode
q->set_only_gcode(false);
+2
View File
@@ -5560,6 +5560,7 @@ if (is_marlin_flavor)
// IDEX/IQEX (IMEX) parallel printing configuration
optgroup = page->new_optgroup(L("IDEX/IQEX Configuration"), L"param_advanced");
optgroup->append_single_option_line("is_imex");
optgroup->append_single_option_line("imex_firmware_managed_zones");
optgroup->append_single_option_line("imex_gantry_count");
optgroup->append_single_option_line("imex_tools_per_gantry");
optgroup->append_single_option_line("imex_tool_layout");
@@ -5982,6 +5983,7 @@ void TabPrinter::toggle_options()
toggle_option(el, is_imex);
toggle_option("imex_tool_layout", is_imex);
toggle_option("imex_viz_theme", is_imex);
toggle_option("imex_firmware_managed_zones", is_imex);
if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex);
}
wxString extruder_number;
+73
View File
@@ -645,3 +645,76 @@ TEST_CASE("group_imex_active_tools_by_gantry — column pairing picks correct re
REQUIRE(g.groups[1].gantry_index == 1);
REQUIRE(g.groups[1].representative_phys == 3); // column-paired to primary T1
}
// -----------------------------------------------------------------------------
// compute_imex_slice_offset — center-origin shift for firmware-managed printers
// -----------------------------------------------------------------------------
// Centered on the plate-local zone at (10,5)..(110,75) → center (60, 40).
static const BoundingBoxf kPrimaryZoneCenteredAt60_40{
Vec2d(10.0, 5.0), Vec2d(110.0, 75.0)};
TEST_CASE("compute_imex_slice_offset — flag off returns zero regardless of mode", "[IMEX]") {
auto z = compute_imex_slice_offset(false, "copy", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
z = compute_imex_slice_offset(false, "mirror", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
z = compute_imex_slice_offset(false, "primary", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — primary mode returns zero even with flag on", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "primary", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — empty mode returns zero even with flag on", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — empty zone box returns zero even with flag on and copy mode", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "copy", std::nullopt);
REQUIRE_THAT(z.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — flag on + copy mode returns primary zone center", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "copy", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(60.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — flag on + mirror mode returns primary zone center", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "mirror", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(60.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — user-defined non-primary mode names trigger shift", "[IMEX]") {
// Mode names are user-defined; anything not "primary" or "" should activate.
auto z = compute_imex_slice_offset(true, "iq-copy", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(60.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset — center value tracks zone box position", "[IMEX]") {
// Right half of a center-origin bed: (0,-75)..(160,75) → center (80, 0).
BoundingBoxf right_half{Vec2d(0.0, -75.0), Vec2d(160.0, 75.0)};
auto z = compute_imex_slice_offset(true, "copy", right_half);
REQUIRE_THAT(z.x(), WithinAbs(80.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
// Left half of a center-origin bed: (-160,-75)..(0,75) → center (-80, 0).
BoundingBoxf left_half{Vec2d(-160.0, -75.0), Vec2d(0.0, 75.0)};
z = compute_imex_slice_offset(true, "mirror", left_half);
REQUIRE_THAT(z.x(), WithinAbs(-80.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(0.0, 1e-9));
}