feat(imex): Span tile state for paired-gantry multicolor

Introduce ImexRole::Span as a 5th tile cycle state that declares "this
tool is the multicolor partner of Primary on the same gantry." Encoded
as the `S` role suffix in `imex_mode_active_tools` (e.g. `0:P,1:S,2:M,3:M`).
Disambiguates paired-gantry mc-mirror from 4-independent-copies — both
share the same active_tools shape sans the marker.

Span drives:
- Multicolor block rule: now requires Span on primary's gantry to allow
  multi-color slicing in a parallel mode. Replaces the prior "≥2 tools
  on primary's gantry" check; pre-existing 4-tool multicolor configs
  need T1 flipped to Span.
- Ghost aggregation: one ghost per non-primary gantry when Span is
  present, using the column-paired representative. Aggregated-mirror
  drag tracks primary 1:1 in X (gantries don't share an X rail) with
  X-flip baked into mesh-local frame so geometry still reads as mirrored.
- Zone aggregation: one full-X row strip per non-primary gantry instead
  of per-tool quadrants.
- UI: 5th button in IMEXModesCtrl. Cycle Off→P→C→M→S→Off, only offered
  on multi-gantry printers and only on tiles sharing primary's gantry row.

Single source of pairing truth: group_imex_active_tools_by_gantry in
IMEXHelpers, consumed by ghost factory and zone calculator.

Also fixes the carriage collision strip's X-boundary check, which lacked
the row constraint its Y-boundary counterpart already had — paired-gantry
mc-mirror was drawing a spurious right-edge strip from T3 sitting
diagonally from primary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Clifford GarwoodandClaude Opus 4.7 committed 2026-05-01 01:16:02 -04:00
1 parent a8dbdf90de
commit 4966d0fae8
5 files changed
+451 -72

No files matched your search

+52 -17
View File
@@ -4395,8 +4395,13 @@ void TabPrinter::build()
// 1 = Primary (green) — the tool the slicer generates paths for
// 2 = Copy (blue) — firmware duplicates Primary at an offset
// 3 = Mirror (amber) — firmware mirrors Primary about an axis
// 4 = Span (yellow) — multicolor partner of Primary on the same gantry;
// declares paired-gantry multicolor topology and
// unlocks the multicolor block at slice time. Only
// offered on tools sharing primary's gantry, and
// only on multi-gantry printers (n_rows >= 2).
//
// Active-tools string format: "idx:P,idx:C,idx:M" (backwards-compat: plain "idx" = Primary)
// Active-tools string format: "idx:P,idx:C,idx:M,idx:S" (backwards-compat: plain "idx" = Primary)
// ---------------------------------------------------------------------------
class IMEXModesCtrl : public wxPanel {
public:
@@ -4430,11 +4435,16 @@ public:
info_panel->SetBackgroundColour(GetBackgroundColour());
auto* info_sizer = new wxBoxSizer(wxVERTICAL);
auto* inst = new wxStaticText(info_panel, wxID_ANY,
wxString instructions =
_L("Each mode defines which tool heads participate and their roles. "
"The Primary tool (green) drives all sliced paths. "
"Copy (blue) and Mirror (amber) tools follow the Primary at the firmware level. "
"Click a tool button to cycle its role — clear the Primary before reassigning it."));
"Click a tool button to cycle its role — clear the Primary before reassigning it.");
if (m_n_rows >= 2)
instructions += " " + _L("Span (yellow) marks a tool on the Primary's gantry as the "
"multicolor partner — required to enable multi-color printing in "
"paired-gantry IDEX/IQEX modes.");
auto* inst = new wxStaticText(info_panel, wxID_ANY, instructions);
inst->Wrap(FromDIP(620));
info_sizer->Add(inst, 0, wxBOTTOM, FromDIP(6));
@@ -4442,9 +4452,12 @@ public:
// matched pairs with their labels regardless of system DPI / font scale,
// matching the on-hover ghost tooltip swatch's visual weight.
auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL);
struct { int state; const char* label; } legend[] = {
struct LegendEntry { int state; const char* label; };
std::vector<LegendEntry> legend = {
{1,"Primary"},{2,"Copy"},{3,"Mirror"}
};
if (m_n_rows >= 2)
legend.push_back({4, "Span"}); // only meaningful on multi-gantry printers
const int swatch_side = info_panel->GetCharHeight();
for (auto& l : legend) {
auto* swatch = new wxPanel(info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side));
@@ -4583,8 +4596,9 @@ public:
if (colon != std::string::npos) {
idx = std::stoi(tok.substr(0, colon));
char role = std::toupper((unsigned char)tok[colon + 1]);
if (role == 'C') state = 2;
if (role == 'C') state = 2;
else if (role == 'M') state = 3;
else if (role == 'S') state = 4;
} else {
idx = std::stoi(tok);
}
@@ -4595,18 +4609,25 @@ public:
}
private:
// State 0=inactive, 1=primary, 2=copy, 3=mirror
// State 0=inactive, 1=primary, 2=copy, 3=mirror, 4=span
static wxColour btn_color(int state) {
switch (state) {
case 1: return wxColour(50, 160, 50); // green — Primary
case 2: return wxColour(60, 120, 210); // blue — Copy
case 3: return wxColour(210, 130, 20); // amber — Mirror
default: return wxColour(90, 90, 90); // grey — Inactive
case 1: return wxColour(50, 160, 50); // green — Primary
case 2: return wxColour(60, 120, 210); // blue — Copy
case 3: return wxColour(210, 130, 20); // amber — Mirror
case 4: return wxColour(180, 180, 40); // yellow — Span
default: return wxColour(90, 90, 90); // grey — Inactive
}
}
static wxColour btn_fg(int /*state*/) { return *wxWHITE; }
static const char* state_role(int state) {
switch (state) { case 1: return "P"; case 2: return "C"; case 3: return "M"; default: return ""; }
switch (state) {
case 1: return "P";
case 2: return "C";
case 3: return "M";
case 4: return "S";
default: return "";
}
}
void apply_btn(wxButton* btn, int tool_idx, int state) {
@@ -4754,19 +4775,33 @@ private:
for (auto& row_ref : m_rows) {
if (row_ref.panel != this_panel) continue;
int& st = row_ref.btn_states[btn_pos];
const int tidx = row_ref.btn_tool_idx[btn_pos];
// Check if another button already holds the Primary state
// Check if another button already holds the Primary state,
// and locate the primary's gantry row for Span eligibility.
bool other_primary = false;
for (int j = 0; j < (int)row_ref.btn_states.size(); ++j)
if (j != btn_pos && row_ref.btn_states[j] == 1) { other_primary = true; break; }
int primary_gantry = -1;
for (int j = 0; j < (int)row_ref.btn_states.size(); ++j) {
if (row_ref.btn_states[j] == 1) {
if (j != btn_pos) other_primary = true;
primary_gantry = row_ref.btn_tool_idx[j] / m_n_cols;
}
}
// Span makes sense only on multi-gantry printers, and only
// on a tile sharing primary's gantry (it declares "this tool
// is the within-gantry multicolor partner of Primary").
const bool span_eligible = (m_n_rows >= 2)
&& (primary_gantry >= 0)
&& (tidx / m_n_cols == primary_gantry);
// Advance state, skipping Primary(1) if another tool is already Primary
st = (st + 1) % 4;
// Cycle: 0 → 1 → 2 → 3 → 4 → 0 with skips.
st = (st + 1) % 5;
if (st == 1 && other_primary)
st = 2; // skip Primary → go straight to Copy
if (st == 4 && !span_eligible)
st = 0; // skip Span → wrap to Inactive
// Keep all_tool_states in sync so off-screen tools are preserved
int tidx = row_ref.btn_tool_idx[btn_pos];
if (st == 0)
row_ref.all_tool_states.erase(tidx);
else