Merge branch 'feedback' into main

Includes two feature sets on top of the base iXex implementation:

Auto-arrange iXex zone constraint:
  Constrains placement to the primary zone when a parallel mode is active.
  Collision strips registered as hard obstacles via m_unselected so the
  NFP placer correctly excludes them.

Nozzle clearance rename + carriage box visualization overhaul:
  ixex_carriage_width_x/y → ixex_nozzle_clearance_x/y (breaking rename).
  Collision strip width now uses the literal clearance value, not half.
  Carriage footprint boxes place the nozzle at the correct physical edge
  for all printer types (IDEX, IQEX 2x2) and modes (copy, mirror).
  Fixed GLModel::reset() bug causing stale mesh on config change.
This commit is contained in:
Clifford Garwood
2026-04-06 15:06:49 -04:00
9 changed files with 115 additions and 44 deletions
+1 -1
View File
@@ -1042,7 +1042,7 @@ static std::vector<std::string> s_Preset_printer_options {
"bed_temperature_formula", "nozzle_flush_dataset",
// iXex (independent X extruder) — printer capability and user-defined modes
"is_ixex", "ixex_gantry_count", "ixex_tools_per_gantry", "ixex_tool_layout",
"ixex_carriage_width_x", "ixex_carriage_width_y", "ixex_carriage_margin", "ixex_viz_theme",
"ixex_nozzle_clearance_x", "ixex_nozzle_clearance_y", "ixex_carriage_margin", "ixex_viz_theme",
"ixex_mode_names", "ixex_mode_active_tools", "ixex_mode_gcodes"
};
+6 -6
View File
@@ -5530,17 +5530,17 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionString("front-left"));
def = this->add("ixex_carriage_width_x", coFloat);
def->label = L("Carriage Width X");
def->tooltip = L("Toolhead footprint in the X direction (mm). Used to calculate usable bed zones and detect carriage collisions.");
def = this->add("ixex_nozzle_clearance_x", coFloat);
def->label = L("Nozzle Clearance X");
def->tooltip = L("Distance (mm) from the nozzle to the collision-side carriage edge in X. Used to calculate the width of the collision exclusion strip at each X boundary.");
def->sidetext = L("mm");
def->min = 0;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloat(30.0));
def = this->add("ixex_carriage_width_y", coFloat);
def->label = L("Carriage Width Y");
def->tooltip = L("Toolhead footprint in the Y direction (mm). Used for collision detection.");
def = this->add("ixex_nozzle_clearance_y", coFloat);
def->label = L("Nozzle Clearance Y");
def->tooltip = L("Distance (mm) from the nozzle to the collision-side carriage edge in Y. Used to calculate the width of the collision exclusion strip at each Y boundary.");
def->sidetext = L("mm");
def->min = 0;
def->mode = comAdvanced;
+2 -2
View File
@@ -1341,8 +1341,8 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionInt, ixex_tools_per_gantry))
((ConfigOptionInt, ixex_primary_col))
((ConfigOptionInt, ixex_primary_row))
((ConfigOptionFloat, ixex_carriage_width_x))
((ConfigOptionFloat, ixex_carriage_width_y))
((ConfigOptionFloat, ixex_nozzle_clearance_x))
((ConfigOptionFloat, ixex_nozzle_clearance_y))
((ConfigOptionStrings, ixex_mode_names))
((ConfigOptionStrings, ixex_mode_active_tools))
((ConfigOptionStrings, ixex_mode_gcodes))
+47 -16
View File
@@ -1521,7 +1521,7 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
{ 0.835f, 0.369f, 0.000f, 0.65f }, // secondary 2 — vermilion (#D55E00)
{ 0.800f, 0.475f, 0.655f, 0.65f }, // secondary 3 — reddish purple (#CC79A7)
}};
struct CarriageDraw { Vec3f pos; ColorRGBA color; };
struct CarriageDraw { Vec3f pos; ColorRGBA color; float box_offset_x = 0.0f; float box_offset_y = 0.0f; };
std::vector<CarriageDraw> carriage_box_draws;
float ixex_box_wx = 0.0f, ixex_box_wy = 0.0f;
{
@@ -1545,8 +1545,8 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
auto* active_tools_opt = printer_cfg.opt<ConfigOptionStrings>("ixex_mode_active_tools");
auto* tpg_opt = printer_cfg.opt<ConfigOptionInt>("ixex_tools_per_gantry");
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("ixex_carriage_width_x");
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("ixex_carriage_width_y");
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("ixex_nozzle_clearance_x");
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("ixex_nozzle_clearance_y");
int tools_per_gantry = tpg_opt ? std::max(1, tpg_opt->value) : 1;
ixex_box_wx = wx_opt ? (float)wx_opt->value : 30.0f;
ixex_box_wy = wy_opt ? (float)wy_opt->value : 30.0f;
@@ -1665,8 +1665,19 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
}
}
// Primary carriage box
carriage_box_draws.push_back({ prim_pos, s_carriage_colors[0] });
// Primary carriage box: nozzle sits at the collision-side edge.
// Default to centered; override once we know which side the secondaries are on.
float pri_box_offset_x = (pri_phys_col == 0) ? 0.0f : -ixex_box_wx; // nozzle at inner edge (facing center of bed)
float pri_box_offset_y = -ixex_box_wy; // nozzle at high-Y edge by default (gantry always behind nozzle); override if secondary is behind
for (int i = 0; i < sec_count; ++i) {
int sc = phys_col_of(sec_tool_ids[i]);
int sr = phys_row_of(sec_tool_ids[i]);
if (sc > pri_phys_col) { pri_box_offset_x = 0.0f; }
else if (sc < pri_phys_col) { pri_box_offset_x = -ixex_box_wx; }
if (sr > pri_phys_row) { pri_box_offset_y = 0.0f; }
else if (sr < pri_phys_row) { pri_box_offset_y = -ixex_box_wy; }
}
carriage_box_draws.push_back({ prim_pos, s_carriage_colors[0], pri_box_offset_x, pri_box_offset_y });
const float pri_zone_x = bed_x_min + (float)pri_phys_col * strip_width;
const float pri_zone_y = bed_y_min + (float)pri_phys_row * row_strip_height;
@@ -1704,8 +1715,26 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
Vec3f sec_pos{ sec_x, sec_y, prim_pos.z() };
m_sequential_view.m_ixex_secondary_markers[i].set_world_position(sec_pos);
m_sequential_view.m_ixex_secondary_markers[i].set_z_offset(m_z_offset + 0.5f);
// X: copy tools move in sync with primary so they share the same
// X-facing orientation. Mirror tools approach from the opposite side
// so the nozzle sits at the collision-side edge.
float sec_box_offset_x;
if (sec_state == 2) {
sec_box_offset_x = pri_box_offset_x;
} else {
if (sec_phys_col > pri_phys_col) sec_box_offset_x = -ixex_box_wx;
else if (sec_phys_col < pri_phys_col) sec_box_offset_x = 0.0f;
else sec_box_offset_x = pri_box_offset_x;
}
// Y: always row-based — gantry is always behind nozzle regardless of
// copy/mirror (a back-row copy still has its gantry at the back).
float sec_box_offset_y;
if (sec_phys_row > pri_phys_row) sec_box_offset_y = -ixex_box_wy;
else if (sec_phys_row < pri_phys_row) sec_box_offset_y = 0.0f;
else sec_box_offset_y = -ixex_box_wy;
carriage_box_draws.push_back({
sec_pos, s_carriage_colors[(i + 1) % s_carriage_colors.size()] });
sec_pos, s_carriage_colors[(i + 1) % s_carriage_colors.size()],
sec_box_offset_x, sec_box_offset_y });
}
}
}
@@ -1719,17 +1748,19 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
m_sequential_view.render(!m_no_render_path, legend_height, &m_viewer, m_viewer.get_current_vertex().gcode_id, canvas_width, canvas_height - bottom_margin * m_scale, right_margin * m_scale, m_viewer.get_view_type());
// iXex: render toolhead footprint boxes for each active carriage.
// Each box is ixex_carriage_width_x × ixex_carriage_width_y, sitting above the nozzle tip.
// Each box is ixex_nozzle_clearance_x × ixex_nozzle_clearance_y, sitting above the nozzle tip.
if (!carriage_box_draws.empty() && ixex_box_wx > 0.0f && ixex_box_wy > 0.0f) {
// Rebuild box mesh if dimensions changed
const Vec2f new_dims{ ixex_box_wx, ixex_box_wy };
if (!m_ixex_toolhead_box.is_initialized() || new_dims != m_ixex_toolhead_box_dims) {
m_ixex_toolhead_box_dims = new_dims;
const float box_h = std::max(ixex_box_wx, ixex_box_wy); // height ~ largest horizontal dim
// Rebuild box mesh every frame — dimensions can change via config edit without a
// G-code reload, so dimension-based caching isn't safe.
// Mesh origin: nozzle at x=0, centered in Y, Z starts at nozzle tip level.
// Per-carriage box_offset_x shifts the mesh left or right so the nozzle lands
// at the correct (collision-side) edge.
{
const float box_h = std::max(ixex_box_wx, ixex_box_wy);
indexed_triangle_set its = its_make_cube((double)ixex_box_wx, (double)ixex_box_wy, (double)box_h);
// Center in X and Y; Z starts at 0 (nozzle tip level)
for (auto& v : its.vertices)
v += Vec3f(-ixex_box_wx * 0.5f, -ixex_box_wy * 0.5f, 0.0f);
// No vertex pre-shifting — box_offset_x/y in the per-carriage transform
// positions the nozzle at the correct collision-side edge.
m_ixex_toolhead_box.reset();
m_ixex_toolhead_box.init_from(its);
}
@@ -1744,7 +1775,7 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
const Transform3d& view_matrix = camera.get_view_matrix();
for (const auto& draw : carriage_box_draws) {
const Transform3d model_matrix = Geometry::translation_transform(
(draw.pos + Vec3f(0.0f, 0.0f, m_z_offset)).cast<double>());
(draw.pos + Vec3f(draw.box_offset_x, draw.box_offset_y, m_z_offset)).cast<double>());
shader->set_uniform("view_model_matrix", view_matrix * model_matrix);
const Matrix3d view_normal_matrix =
view_matrix.matrix().block(0, 0, 3, 3) *
-1
View File
@@ -207,7 +207,6 @@ private:
std::string m_marker_filename; // cached for lazy secondary marker init
std::string m_ixex_last_mode; // detect mode changes for secondary marker rebuild
GLModel m_ixex_toolhead_box; // shared box mesh for all carriage footprint overlays
Vec2f m_ixex_toolhead_box_dims{ 0.0f, 0.0f }; // (width_x, width_y) — rebuild when changed
size_t m_extruders_count;
std::vector<float> m_filament_diameters;
+31
View File
@@ -540,6 +540,37 @@ void ArrangeJob::process(Ctl &ctl)
Points bedpts = get_shrink_bedpts(m_plater->config(),params);
// When an iXex parallel mode is active, constrain auto-arrange to the primary zone only
// and treat carriage collision strips as hard excluded regions.
if (PartPlate* curr_plate = partplate_list.get_curr_plate()) {
if (auto pz = curr_plate->ixex_primary_zone()) {
BoundingBox scaled_pz = scaled(*pz);
bedpts = {
{ scaled_pz.min.x(), scaled_pz.min.y() },
{ scaled_pz.max.x(), scaled_pz.min.y() },
{ scaled_pz.max.x(), scaled_pz.max.y() },
{ scaled_pz.min.x(), scaled_pz.max.y() },
};
for (const BoundingBoxf3& cz : curr_plate->ixex_collision_zones()) {
Polygon poly({
{ scaled(cz.min.x()), scaled(cz.min.y()) },
{ scaled(cz.max.x()), scaled(cz.min.y()) },
{ scaled(cz.max.x()), scaled(cz.max.y()) },
{ scaled(cz.min.x()), scaled(cz.max.y()) },
});
arrangement::ArrangePolygon ap;
ap.poly.contour = poly;
ap.translation = Vec2crd(0, 0);
ap.rotation = 0.0;
ap.is_virt_object = true;
ap.bed_idx = current_plate_index;
ap.height = 1;
ap.name = "IXexCollisionZone";
m_unselected.emplace_back(std::move(ap));
}
}
}
bool enable_wrapping = global_config.option<ConfigOptionBool>("enable_wrapping_detection")->value;
partplate_list.preprocess_exclude_areas(params.excluded_regions, enable_wrapping, 1, scale_(1));
+17 -15
View File
@@ -465,6 +465,7 @@ void PartPlate::calc_ixex_zones()
m_ixex_collision_zones.clear();
m_ixex_collision_overlay.clear();
m_ixex_margin_overlay.clear();
m_ixex_primary_zone_box = std::nullopt;
if (!wxGetApp().preset_bundle)
return;
@@ -652,18 +653,18 @@ void PartPlate::calc_ixex_zones()
// Only add strips at boundaries of the PRIMARY zone — objects are only placed in the
// primary zone, so secondary-to-secondary boundaries have no relevance.
//
// Strip width is carriage/2 on the primary side only:
// right X boundary: [bnd_x - carriage_w/2, bnd_x]
// left X boundary: [bnd_x, bnd_x + carriage_w/2]
// top Y boundary: [bnd_y - carriage_h/2, bnd_y]
// bottom Y boundary:[bnd_y, bnd_y + carriage_h/2]
// Strip width is the literal nozzle clearance value on the primary side only:
// right X boundary: [bnd_x - nozzle_clearance_x, bnd_x]
// left X boundary: [bnd_x, bnd_x + nozzle_clearance_x]
// top Y boundary: [bnd_y - nozzle_clearance_y, bnd_y]
// bottom Y boundary:[bnd_y, bnd_y + nozzle_clearance_y]
//
// Strip length matches the primary zone extent (same expansion logic as make_boxes):
// !has_row_sep → full bed height; has_row_sep → primary row only
// !has_col_sep → full bed width; has_col_sep → primary column only
auto* cw_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_width_x");
auto* ch_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_width_y");
auto* cw_opt = printer_cfg.option<ConfigOptionFloat>("ixex_nozzle_clearance_x");
auto* ch_opt = printer_cfg.option<ConfigOptionFloat>("ixex_nozzle_clearance_y");
auto* mgn_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_margin");
double carriage_w = cw_opt ? cw_opt->value : 0.0;
double carriage_h = ch_opt ? ch_opt->value : 0.0;
@@ -689,6 +690,7 @@ void PartPlate::calc_ixex_zones()
double pz_x1 = has_col_sep ? x_min + (pri_col + 1) * zone_w : x_max;
double pz_y0 = has_row_sep ? y_min + pri_row * zone_h : y_min;
double pz_y1 = has_row_sep ? y_min + (pri_row + 1) * zone_h : y_max;
m_ixex_primary_zone_box = BoundingBoxf(Vec2d(pz_x0, pz_y0), Vec2d(pz_x1, pz_y1));
// Helper: build a BoundingBoxf3 strip, clip to bed, add to members
auto add_strip = [&](double sx0, double sx1, double sy0, double sy1) {
@@ -727,36 +729,36 @@ void PartPlate::calc_ixex_zones()
if (r == pri_row - 1 && c == pri_col) has_bottom_sec = true;
}
// X-axis boundaries (vertical strips, width = carriage_w/2 on primary side)
// X-axis boundaries (vertical strips, width = nozzle_clearance_x on primary side)
if (carriage_w > 0.0) {
if (has_right_sec) {
double bnd_x = x_min + (pri_col + 1) * zone_w;
double strip_inner = bnd_x - carriage_w * 0.5;
double strip_inner = bnd_x - carriage_w;
add_strip(strip_inner, bnd_x, pz_y0, pz_y1);
if (margin > 0.0)
add_margin_fill(strip_inner - margin, strip_inner, pz_y0, pz_y1);
}
if (has_left_sec) {
double bnd_x = x_min + pri_col * zone_w;
double strip_inner = bnd_x + carriage_w * 0.5;
double strip_inner = bnd_x + carriage_w;
add_strip(bnd_x, strip_inner, pz_y0, pz_y1);
if (margin > 0.0)
add_margin_fill(strip_inner, strip_inner + margin, pz_y0, pz_y1);
}
}
// Y-axis boundaries (horizontal strips, width = carriage_h/2 on primary side)
// Y-axis boundaries (horizontal strips, width = nozzle_clearance_y on primary side)
if (carriage_h > 0.0) {
if (has_top_sec) {
double bnd_y = y_min + (pri_row + 1) * zone_h;
double strip_inner = bnd_y - carriage_h * 0.5;
double strip_inner = bnd_y - carriage_h;
add_strip(pz_x0, pz_x1, strip_inner, bnd_y);
if (margin > 0.0)
add_margin_fill(pz_x0, pz_x1, strip_inner - margin, strip_inner);
}
if (has_bottom_sec) {
double bnd_y = y_min + pri_row * zone_h;
double strip_inner = bnd_y + carriage_h * 0.5;
double strip_inner = bnd_y + carriage_h;
add_strip(pz_x0, pz_x1, bnd_y, strip_inner);
if (margin > 0.0)
add_margin_fill(pz_x0, pz_x1, strip_inner, strip_inner + margin);
@@ -777,8 +779,8 @@ static std::string build_ixex_cache_key()
auto* mode_opt = process_cfg.option<ConfigOptionString>("ixex_parallel_mode");
auto* n_col_opt = printer_cfg.option<ConfigOptionInt>("ixex_tools_per_gantry");
auto* n_row_opt = printer_cfg.option<ConfigOptionInt>("ixex_gantry_count");
auto* cw_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_width_x");
auto* ch_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_width_y");
auto* cw_opt = printer_cfg.option<ConfigOptionFloat>("ixex_nozzle_clearance_x");
auto* ch_opt = printer_cfg.option<ConfigOptionFloat>("ixex_nozzle_clearance_y");
auto* mgn_opt = printer_cfg.option<ConfigOptionFloat>("ixex_carriage_margin");
return (mode_opt ? mode_opt->value : "primary")
+ "|" + std::to_string(n_col_opt ? n_col_opt->value : 2)
+9 -1
View File
@@ -4,6 +4,7 @@
#include <vector>
#include <set>
#include <array>
#include <optional>
#include <thread>
#include <mutex>
@@ -137,10 +138,11 @@ private:
GLModel m_gridlines_bolder;
std::vector<GLModel> m_ixex_copy_zones; // blue tint (Wong #56B4E9)
std::vector<GLModel> m_ixex_mirror_zones; // orange tint (Wong #E69F00)
std::vector<BoundingBoxf3> m_ixex_secondary_zone_boxes; // secondary (copy/mirror) zone rects — objects blocked here
std::vector<BoundingBoxf3> m_ixex_secondary_zone_boxes; // secondary (copy/mirror) zone rects — objects blocked here
std::vector<BoundingBoxf3> m_ixex_collision_zones; // carriage danger strips inside primary zone (mm)
std::vector<GLModel> m_ixex_collision_overlay; // red-orange rendered fill for danger strips
std::vector<GLModel> m_ixex_margin_overlay; // amber advisory bands just inside collision strips
std::optional<BoundingBoxf> m_ixex_primary_zone_box; // primary zone extents in mm; empty when iXex is off/primary-only
std::string m_ixex_zones_mode_cache{ "\x01" }; // cached mode+topology key; sentinel = unbuilt
GLModel m_height_limit_common;
GLModel m_height_limit_bottom;
@@ -444,6 +446,12 @@ public:
}
// Returns true if any instance on this plate overlaps an iXex secondary or collision zone.
bool has_ixex_placement_violations();
// Returns the primary-zone bounding box in mm when an iXex parallel mode is active.
// Empty (nullopt) when iXex is off or the mode is "primary" (full-bed).
std::optional<BoundingBoxf> ixex_primary_zone() { ensure_ixex_zones(); return m_ixex_primary_zone_box; }
// Returns carriage collision strips (mm) for the current iXex mode.
// Always call ixex_primary_zone() first to ensure the cache is warm.
const std::vector<BoundingBoxf3>& ixex_collision_zones() const { return m_ixex_collision_zones; }
void update_slice_ready_status(bool ready_slice)
{
m_ready_for_slice = ready_slice;
+2 -2
View File
@@ -5002,8 +5002,8 @@ void TabPrinter::build_fff()
};
optgroup->append_line(line);
}
optgroup->append_single_option_line("ixex_carriage_width_x");
optgroup->append_single_option_line("ixex_carriage_width_y");
optgroup->append_single_option_line("ixex_nozzle_clearance_x");
optgroup->append_single_option_line("ixex_nozzle_clearance_y");
optgroup->append_single_option_line("ixex_carriage_margin");
{