mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
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:
co-authored by
Claude Opus 4.7
parent
a8dbdf90de
commit
4966d0fae8
@@ -86,17 +86,20 @@ std::string imex_multicolor_block_reason(const std::string& parallel_mode,
|
||||
"editor and assign a Primary role to one tool.";
|
||||
}
|
||||
|
||||
// Walk the active tools once: count how many sit on the primary's gantry, and
|
||||
// collect the set of distinct gantries spanned by the mode. Both the single-
|
||||
// gantry check and the within-gantry-swap check fall out of this walk.
|
||||
// Walk the active tools once: collect the set of distinct gantries spanned by
|
||||
// the mode and check for an explicit Span marker on the primary's gantry.
|
||||
// Span declares "this tool is the multicolor partner of primary on the same
|
||||
// gantry" — without it, two tools on primary's gantry mean two independent
|
||||
// copies, not a within-gantry toolchange topology, which can't carry
|
||||
// multicolor in a parallel mode.
|
||||
const int tpg = std::max(1, tools_per_gantry);
|
||||
const int primary_gantry = primary_physical / tpg;
|
||||
int tools_on_primary_gantry = 0;
|
||||
bool span_on_primary_gantry = false;
|
||||
std::set<int> active_gantries;
|
||||
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str)) {
|
||||
active_gantries.insert(phys / tpg);
|
||||
if (phys / tpg == primary_gantry)
|
||||
++tools_on_primary_gantry;
|
||||
if (phys / tpg == primary_gantry && role == ImexRole::Span)
|
||||
span_on_primary_gantry = true;
|
||||
}
|
||||
|
||||
// Single-gantry mode: the active tools all sit on one gantry, so no second
|
||||
@@ -113,15 +116,17 @@ std::string imex_multicolor_block_reason(const std::string& parallel_mode,
|
||||
"tools on a second gantry.";
|
||||
}
|
||||
|
||||
// Dual-gantry mode but no within-gantry toolchange topology on the primary's
|
||||
// own gantry — the slicer would emit toolchanges between filaments but the
|
||||
// primary gantry has only one active tool, so the swap can't physically happen.
|
||||
if (tools_on_primary_gantry < 2) {
|
||||
return "Multi-color prints in IDEX/IQEX parallel modes require at least two tools "
|
||||
"assigned to the primary tool's gantry — there is no within-gantry "
|
||||
"toolchange path otherwise. Either reduce the print to a single filament, "
|
||||
"switch to Primary mode, or define a parallel mode that pairs two tools on "
|
||||
"the primary gantry.";
|
||||
// Dual-gantry mode but no Span tool on the primary's gantry — the slicer would
|
||||
// emit toolchanges between filaments but the mode doesn't declare a within-gantry
|
||||
// multicolor partner, so the swap can't carry. The user might intend independent
|
||||
// copies (each gantry tool prints its own object) which is incompatible with
|
||||
// mid-print multicolor in a parallel mode.
|
||||
if (!span_on_primary_gantry) {
|
||||
return "Multi-color prints in IDEX/IQEX parallel modes require a Span tool on the "
|
||||
"primary's gantry — without one, the mode doesn't declare a within-gantry "
|
||||
"multicolor partner. Either reduce the print to a single filament, switch to "
|
||||
"Primary mode, or open the printer settings IDEX/IQEX Modes editor and mark a "
|
||||
"tool on the primary's gantry as Span.";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -183,6 +188,7 @@ std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string&
|
||||
const std::string r = tok.substr(colon + 1);
|
||||
if (r == "P") role = ImexRole::Primary;
|
||||
else if (r == "M") role = ImexRole::Mirror;
|
||||
else if (r == "S") role = ImexRole::Span;
|
||||
// "C" and anything else → Copy.
|
||||
}
|
||||
out.emplace_back(phys, role);
|
||||
@@ -190,6 +196,63 @@ std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string&
|
||||
return out;
|
||||
}
|
||||
|
||||
ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode,
|
||||
int tools_per_gantry)
|
||||
{
|
||||
ImexGantryGrouping out;
|
||||
const int tpg = std::max(1, tools_per_gantry);
|
||||
|
||||
const int primary = imex_primary_tool_for_mode(active_tools_for_mode);
|
||||
if (primary < 0)
|
||||
return out;
|
||||
out.primary_phys = primary;
|
||||
out.primary_gantry = primary / tpg;
|
||||
|
||||
const int primary_col = primary % tpg;
|
||||
|
||||
// Bucket every active tool by its gantry index. Skip the primary entry itself
|
||||
// since it carries no role-driven semantics for grouping (it's just the source).
|
||||
std::map<int, std::vector<std::pair<int, ImexRole>>> by_gantry;
|
||||
bool span_seen = false;
|
||||
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_for_mode)) {
|
||||
if (phys < 0) continue;
|
||||
const int g = phys / tpg;
|
||||
by_gantry[g].emplace_back(phys, role);
|
||||
if (g == out.primary_gantry && role == ImexRole::Span)
|
||||
span_seen = true;
|
||||
}
|
||||
out.span_on_primary = span_seen;
|
||||
|
||||
for (auto& [g, tools] : by_gantry) {
|
||||
ImexGantryGroup grp;
|
||||
grp.gantry_index = g;
|
||||
grp.tools = tools;
|
||||
|
||||
// Pick representative: column-paired to primary if active, else lowest phys.
|
||||
const int paired_phys = g * tpg + primary_col;
|
||||
auto pick = std::find_if(tools.begin(), tools.end(),
|
||||
[&](const auto& pr) { return pr.first == paired_phys; });
|
||||
if (pick == tools.end())
|
||||
pick = std::min_element(tools.begin(), tools.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
grp.representative_phys = pick->first;
|
||||
grp.representative_role = pick->second;
|
||||
|
||||
// Aggregate iff Span is on primary's gantry, this is a non-primary gantry,
|
||||
// it has ≥2 tools, and all those tools share the same role (so the merged
|
||||
// visualization is unambiguous). Mixed-role gantries fall back to per-tool.
|
||||
const bool same_role = std::all_of(tools.begin(), tools.end(),
|
||||
[&](const auto& pr) { return pr.second == grp.representative_role; });
|
||||
grp.aggregate = span_seen
|
||||
&& g != out.primary_gantry
|
||||
&& tools.size() >= 2
|
||||
&& same_role;
|
||||
|
||||
out.groups.push_back(std::move(grp));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int imex_primary_tool_for_mode(const std::string& active_tools_for_mode)
|
||||
{
|
||||
if (active_tools_for_mode.empty())
|
||||
|
||||
@@ -111,17 +111,20 @@ bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned
|
||||
// Multi-color in a parallel mode requires the firmware to swap tools mid-print on the
|
||||
// primary gantry while the slaved gantry follows automatically. That works only when
|
||||
// every used filament has its own dedicated physical head (no MMU lane sharing) AND
|
||||
// the active mode definition assigns at least 2 roles to tools on the primary's gantry
|
||||
// (so there's a within-gantry toolchange topology to swap among).
|
||||
// the active mode definition explicitly marks a Span tool on the primary's gantry
|
||||
// (Span declares "this tool is the within-gantry multicolor partner of Primary"; it
|
||||
// disambiguates the multicolor topology from independent same-gantry copies).
|
||||
//
|
||||
// Catches:
|
||||
// - IDEX (1 tool per gantry): only 1 tool on primary's gantry → blocked
|
||||
// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): only 1 tool
|
||||
// on primary's gantry → blocked
|
||||
// - IDEX (1 tool per gantry): primary's gantry can never carry a Span partner → blocked
|
||||
// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): no Span on
|
||||
// primary's gantry → blocked
|
||||
// - MMU/AFC sharing among used filaments: multiple used filaments routed to same
|
||||
// physical head → blocked (the slaved gantry can't follow MMU lane swaps)
|
||||
// - IQEX 4-tool-active (T0+T1 on primary gantry, T2+T3 paired): 2 tools on primary's
|
||||
// gantry, no MMU sharing → ALLOWED
|
||||
// - IQEX 4-tool independent copies (T0:P,T1:C,T2:M,T3:M): no Span declared, T1 is an
|
||||
// independent copy → blocked
|
||||
// - IQEX paired-gantry multicolor (T0:P,T1:S,T2:M,T3:M): Span on T1 declares the
|
||||
// multicolor partner, no MMU sharing → ALLOWED
|
||||
//
|
||||
// Returns empty string for: non-IMEX (empty parallel_mode), Primary mode, single-color
|
||||
// prints, or any configuration where multi-color is physically supportable.
|
||||
@@ -212,7 +215,7 @@ std::vector<int> imex_secondary_logical_slots(const std::vector<int>& active_p
|
||||
const std::map<int,int>& plate_head_filament_map,
|
||||
const ConfigOptionInts& pem);
|
||||
|
||||
enum class ImexRole { Primary, Copy, Mirror };
|
||||
enum class ImexRole { Primary, Copy, Mirror, Span };
|
||||
|
||||
// Parses `imex_mode_active_tools[mode]` into a list of (physical_head, role) pairs.
|
||||
// Accepted token forms (comma-separated, whitespace-tolerant):
|
||||
@@ -220,6 +223,10 @@ enum class ImexRole { Primary, Copy, Mirror };
|
||||
// "phys:P" — Primary
|
||||
// "phys:C" — Copy
|
||||
// "phys:M" — Mirror
|
||||
// "phys:S" — Span (multicolor partner of Primary on the same gantry; only
|
||||
// meaningful on tools sharing primary's gantry, and only on a
|
||||
// multi-gantry printer. Drives the multicolor-allow path and
|
||||
// paired-gantry ghost/zone aggregation.)
|
||||
// "phys:???" — unknown role suffix, treated as Copy
|
||||
// Malformed tokens (unparseable int, negative phys) are skipped.
|
||||
// NOTE: the bare-token → Copy default differs from `imex_primary_tool_for_mode`,
|
||||
@@ -228,6 +235,39 @@ enum class ImexRole { Primary, Copy, Mirror };
|
||||
// in hand and need the full head/role list (e.g. ghost factory/updater).
|
||||
std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string& active_tools_for_mode);
|
||||
|
||||
// Per-gantry grouping derived from the active_tools string. The single source of
|
||||
// truth for paired-gantry visualization aggregation: when the primary's gantry
|
||||
// has at least one Span tool, every non-primary gantry's representative tool
|
||||
// stands in for the whole gantry (one ghost, one zone strip). Gantries without
|
||||
// the aggregation trigger keep per-tool semantics.
|
||||
//
|
||||
// `representative_phys` is the column-paired tool to primary on that gantry —
|
||||
// i.e. `gantry_index * tools_per_gantry + (primary_phys % tools_per_gantry)` if
|
||||
// active, else the lowest active phys on that gantry. Aggregation falls back
|
||||
// when tools on the same non-primary gantry carry mixed roles (e.g. one Copy
|
||||
// + one Mirror), since the user explicitly authored two distinct topologies.
|
||||
struct ImexGantryGroup {
|
||||
int gantry_index = -1;
|
||||
int representative_phys = -1;
|
||||
ImexRole representative_role = ImexRole::Copy;
|
||||
bool aggregate = false; // collapse this gantry to one ghost/zone
|
||||
std::vector<std::pair<int, ImexRole>> tools; // every active tool on this gantry
|
||||
};
|
||||
|
||||
struct ImexGantryGrouping {
|
||||
int primary_phys = -1;
|
||||
int primary_gantry = -1;
|
||||
bool span_on_primary = false; // any Span tool on primary's gantry
|
||||
std::vector<ImexGantryGroup> groups; // every gantry with ≥1 active tool, sorted by index
|
||||
};
|
||||
|
||||
// Groups parsed active tools by gantry (`phys / tools_per_gantry`). Aggregation
|
||||
// triggers iff `span_on_primary` is true AND a non-primary gantry's tools all
|
||||
// carry the same role; mixed-role non-primary gantries fall back to per-tool.
|
||||
// Returns an empty grouping when active_tools_str is empty / has no primary.
|
||||
ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode,
|
||||
int tools_per_gantry);
|
||||
|
||||
// World-space transform composed as `head_xf * primary_instance_world` to place a ghost
|
||||
// copy of the primary into `target`'s frame under `role`.
|
||||
// `gantry_offset` = center_for(target) - center_for(primary) (XY, in mm).
|
||||
|
||||
+154
-21
@@ -644,13 +644,39 @@ void PartPlate::calc_imex_zones()
|
||||
m_imex_primary_head = idx; break; }
|
||||
}
|
||||
|
||||
// Per-gantry grouping drives Span-based aggregation. When the mode declares a
|
||||
// Span partner on primary's gantry, non-primary gantries with multiple same-role
|
||||
// tools collapse to one cell each (placed at primary's column so has_col_sep
|
||||
// stays false and make_boxes expands the cell into a full-X row-strip).
|
||||
// Mixed-role / single-tool / no-Span configurations keep per-tool cells.
|
||||
const ImexGantryGrouping grouping =
|
||||
group_imex_active_tools_by_gantry(active_tools_str, n_cols);
|
||||
std::map<int, const ImexGantryGroup*> group_by_gantry;
|
||||
for (const auto& grp : grouping.groups)
|
||||
group_by_gantry[grp.gantry_index] = &grp;
|
||||
|
||||
// Separate copy and mirror secondary cells using physical coordinates
|
||||
std::set<std::pair<int,int>> copy_cells, mirror_cells;
|
||||
for (auto& [idx, state] : tool_states) {
|
||||
auto [c, r] = tool_to_phys(idx);
|
||||
if (c == pri_col && r == pri_row) continue; // skip primary
|
||||
if (state == 2) copy_cells.insert({c, r});
|
||||
else if (state == 3) mirror_cells.insert({c, r});
|
||||
if (state == 1) continue; // primary handled separately
|
||||
|
||||
const int phys_gantry = idx / n_cols;
|
||||
auto git = group_by_gantry.find(phys_gantry);
|
||||
const ImexGantryGroup* grp = (git == group_by_gantry.end()) ? nullptr : git->second;
|
||||
|
||||
if (grp && grp->aggregate) {
|
||||
// Only the representative contributes a cell; non-reps are folded into
|
||||
// the same row-strip and skipped entirely.
|
||||
if (idx != grp->representative_phys) continue;
|
||||
auto [rep_c, r] = tool_to_phys(idx);
|
||||
(void)rep_c; // intentionally discarded — aggregated cell pins to pri_col
|
||||
if (state == 2) copy_cells.insert({pri_col, r});
|
||||
else if (state == 3) mirror_cells.insert({pri_col, r});
|
||||
} else {
|
||||
auto [c, r] = tool_to_phys(idx);
|
||||
if (state == 2) copy_cells.insert({c, r});
|
||||
else if (state == 3) mirror_cells.insert({c, r});
|
||||
}
|
||||
}
|
||||
|
||||
// All active secondary cells combined (for separation axis computation)
|
||||
@@ -854,6 +880,12 @@ void PartPlate::calc_imex_zones()
|
||||
// Determine which directions have mirror secondaries adjacent to the primary.
|
||||
// Only mirror tools can cause carriage collisions — they move toward each other.
|
||||
// Copy tools always move in the same direction, so no collision strip is needed.
|
||||
//
|
||||
// Both axes require zone-adjacent AND same row/column on the OTHER axis: a mirror
|
||||
// diagonally offset from primary (different row AND different column) can't collide
|
||||
// with primary's carriage on either axis since the gantries don't overlap there.
|
||||
// Without these checks, paired-gantry mc-mirror (`0:P,1:S,2:M,3:M`) would draw a
|
||||
// spurious right-edge strip from T3 even though T3 lives on the other gantry's row.
|
||||
bool has_right_sec = false, has_left_sec = false;
|
||||
bool has_top_sec = false, has_bottom_sec = false;
|
||||
for (auto& [idx, state] : tool_states) {
|
||||
@@ -861,9 +893,9 @@ void PartPlate::calc_imex_zones()
|
||||
auto [c, r] = tool_to_phys(idx);
|
||||
int zc = col_to_zone.count(c) ? col_to_zone.at(c) : -1;
|
||||
int zr = row_to_zone.count(r) ? row_to_zone.at(r) : -1;
|
||||
// X-boundary strips: mirror in the zone immediately adjacent to the primary zone.
|
||||
if (zc == pri_col_k + 1) has_right_sec = true;
|
||||
if (zc == pri_col_k - 1) has_left_sec = true;
|
||||
// X-boundary strips: mirror zone-adjacent in X, same physical row as primary.
|
||||
if (zr == pri_row_k && zc == pri_col_k + 1) has_right_sec = true;
|
||||
if (zr == pri_row_k && zc == pri_col_k - 1) has_left_sec = true;
|
||||
// Y-boundary strips: mirror zone-adjacent in Y, same physical column as primary.
|
||||
if (zr == pri_row_k + 1 && c == pri_col) has_top_sec = true;
|
||||
if (zr == pri_row_k - 1 && c == pri_col) has_bottom_sec = true;
|
||||
@@ -1056,6 +1088,31 @@ void PartPlate::calc_imex_ghosts()
|
||||
|
||||
const auto heads = parse_imex_active_tools(active_tools_str);
|
||||
|
||||
// When primary's gantry has a Span tool, paired-gantry aggregation means each
|
||||
// non-primary gantry is represented by a single ghost — its column-paired rep —
|
||||
// so non-rep tools on aggregated gantries are skipped. This single source of
|
||||
// pairing truth keeps ghost emission and zone aggregation in lockstep.
|
||||
int tpg = 1;
|
||||
if (auto* tpg_opt = wxGetApp().preset_bundle->printers.get_edited_preset()
|
||||
.config.option<ConfigOptionInt>("imex_tools_per_gantry"))
|
||||
tpg = std::max(1, tpg_opt->value);
|
||||
const ImexGantryGrouping grouping =
|
||||
group_imex_active_tools_by_gantry(active_tools_str, tpg);
|
||||
auto is_aggregated = [&](int phys) -> bool {
|
||||
const int g = phys / tpg;
|
||||
for (const auto& grp : grouping.groups)
|
||||
if (grp.gantry_index == g) return grp.aggregate;
|
||||
return false;
|
||||
};
|
||||
auto skip_for_aggregation = [&](int phys) -> bool {
|
||||
const int g = phys / tpg;
|
||||
for (const auto& grp : grouping.groups) {
|
||||
if (grp.gantry_index != g) continue;
|
||||
return grp.aggregate && phys != grp.representative_phys;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Zone centers are the source of truth for ghost placement: they come from the
|
||||
// same grid math that paints the colored secondary zones, so a ghost always lands
|
||||
// in its own tool's zone. extruder_offset is physical-nozzle data and is left at
|
||||
@@ -1067,6 +1124,25 @@ void PartPlate::calc_imex_ghosts()
|
||||
};
|
||||
const Vec2d primary_off = center_for(primary_phys);
|
||||
|
||||
// For aggregated gantries the zone is a full-X row strip, so mirror has to
|
||||
// reflect across the bed centerline (not primary's column-aligned center) and
|
||||
// copy translates purely along Y. Compose primary_zone_center + gantry_offset
|
||||
// to land each role correctly inside the strip.
|
||||
auto bed_ext = get_extents(m_shape);
|
||||
const double bed_x_center = 0.5 * (bed_ext.min(0) + bed_ext.max(0));
|
||||
auto resolve_centers = [&](int phys) -> std::pair<Vec2d, Vec2d> {
|
||||
const Vec2d target_off = center_for(phys);
|
||||
if (!is_aggregated(phys)) {
|
||||
// Per-tool: target stays at its column-aligned zone center.
|
||||
return {primary_off, target_off - primary_off};
|
||||
}
|
||||
// Aggregated: shift the X frame onto bed centerline so mirror reflects
|
||||
// across the whole bed and copy stays at primary's X within the strip.
|
||||
const Vec2d aggregated_primary{bed_x_center, primary_off.y()};
|
||||
const Vec2d aggregated_target {bed_x_center, target_off.y()};
|
||||
return {aggregated_primary, aggregated_target - aggregated_primary};
|
||||
};
|
||||
|
||||
constexpr float GHOST_ALPHA = 0.55f;
|
||||
|
||||
// Mesh is object-local and identical across all instances and heads of a given object.
|
||||
@@ -1101,19 +1177,38 @@ void PartPlate::calc_imex_ghosts()
|
||||
for (const auto& [phys, role] : heads) {
|
||||
if (phys == primary_phys) continue;
|
||||
if (phys >= IMEX_GHOST_MAX_HEADS) continue;
|
||||
if (role == ImexRole::Span) continue; // within-gantry partner; primary's zone covers it
|
||||
if (skip_for_aggregation(phys)) continue; // non-rep on an aggregated gantry
|
||||
|
||||
const Vec2d gantry = center_for(phys) - primary_off;
|
||||
// Mirror reflects about the zone-boundary plane through the primary zone
|
||||
// center, so the ghost lands at the mirrored position within the target
|
||||
// zone and drag motion inverts X while Y tracks 1:1. Copy ignores it.
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, phys, role, gantry, primary_off);
|
||||
const bool aggregated_mirror = is_aggregated(phys) && role == ImexRole::Mirror;
|
||||
Transform3d ghost_xf;
|
||||
if (aggregated_mirror) {
|
||||
// Span aggregation: gantries don't share an X rail, so reflecting
|
||||
// ghost X motion against primary serves no collision purpose and
|
||||
// makes the ghost drift off-bed when primary drags. Translate 1:1
|
||||
// with primary (copy-style position) and bake the X-flip into the
|
||||
// mesh-local frame so geometry still reads as mirrored. Equivalent
|
||||
// to: ghost_xf = Translate(0, gantry_y, 0) * inst_world * X-flip.
|
||||
const Vec2d target_off = center_for(phys);
|
||||
ghost_xf = inst_world;
|
||||
ghost_xf.linear() = ghost_xf.linear()
|
||||
* Eigen::DiagonalMatrix<double, 3>(-1.0, 1.0, 1.0);
|
||||
ghost_xf.translation().y() += target_off.y() - primary_off.y();
|
||||
} else {
|
||||
const auto [pri_center, gantry] = resolve_centers(phys);
|
||||
// Per-tool mirror still reflects about pri_center.x + gantry.x/2 so
|
||||
// each individual mirror lands inside its own zone. Copy translates by
|
||||
// `gantry`; aggregated copy resolves gantry.x to 0 → pure-Y translate.
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, phys, role, gantry, pri_center);
|
||||
ghost_xf = head_xf * inst_world;
|
||||
}
|
||||
|
||||
ColorRGBA color = get_imex_head_filament_color(phys);
|
||||
color.a(GHOST_ALPHA);
|
||||
|
||||
auto ghost = std::make_unique<GLVolume>(color);
|
||||
ghost->set_instance_transformation(head_xf * inst_world);
|
||||
ghost->set_instance_transformation(ghost_xf);
|
||||
ghost->force_transparent = 1;
|
||||
ghost->force_native_color = 1;
|
||||
ghost->disabled = 1; // skip selection path
|
||||
@@ -1151,6 +1246,32 @@ void PartPlate::update_imex_ghost_transforms(
|
||||
};
|
||||
const Vec2d primary_off = center_for(primary_phys);
|
||||
|
||||
// Same aggregation-aware center resolution as calc_imex_ghosts uses, so live
|
||||
// drags reflect the ghost across bed centerline (not the rep's column-aligned
|
||||
// center) when the gantry is aggregated by Span.
|
||||
int tpg = 1;
|
||||
if (auto* tpg_opt = wxGetApp().preset_bundle->printers.get_edited_preset()
|
||||
.config.option<ConfigOptionInt>("imex_tools_per_gantry"))
|
||||
tpg = std::max(1, tpg_opt->value);
|
||||
const ImexGantryGrouping grouping =
|
||||
group_imex_active_tools_by_gantry(active_tools_str, tpg);
|
||||
auto is_aggregated = [&](int phys) -> bool {
|
||||
const int g = phys / tpg;
|
||||
for (const auto& grp : grouping.groups)
|
||||
if (grp.gantry_index == g) return grp.aggregate;
|
||||
return false;
|
||||
};
|
||||
auto bed_ext = get_extents(m_shape);
|
||||
const double bed_x_center = 0.5 * (bed_ext.min(0) + bed_ext.max(0));
|
||||
auto resolve_centers = [&](int phys) -> std::pair<Vec2d, Vec2d> {
|
||||
const Vec2d target_off = center_for(phys);
|
||||
if (!is_aggregated(phys))
|
||||
return {primary_off, target_off - primary_off};
|
||||
const Vec2d ap{bed_x_center, primary_off.y()};
|
||||
const Vec2d at{bed_x_center, target_off.y()};
|
||||
return {ap, at - ap};
|
||||
};
|
||||
|
||||
// Build a phys → role map once so the per-ghost loop is a lookup, not a reparse.
|
||||
std::map<int, ImexRole> role_by_phys;
|
||||
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str))
|
||||
@@ -1182,13 +1303,25 @@ void PartPlate::update_imex_ghost_transforms(
|
||||
primary_xf = mo->instances[inst_idx]->get_matrix();
|
||||
}
|
||||
|
||||
const Vec2d gantry = center_for(head) - primary_off;
|
||||
// Mirror reflects about the zone-boundary plane through the primary zone
|
||||
// center, so the ghost lands at the mirrored position within the target
|
||||
// zone and drag motion inverts X while Y tracks 1:1. Copy ignores it.
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, head, role_for(head), gantry, primary_off);
|
||||
ghost->set_instance_transformation(head_xf * primary_xf);
|
||||
const ImexRole role = role_for(head);
|
||||
const bool aggregated_mirror = is_aggregated(head) && role == ImexRole::Mirror;
|
||||
Transform3d ghost_xf;
|
||||
if (aggregated_mirror) {
|
||||
// Span aggregation: drop X reflection — gantries don't share an X rail
|
||||
// so reflecting motion serves no collision purpose. Translate 1:1 in X
|
||||
// and bake X-flip into mesh-local frame so geometry still mirrors.
|
||||
const Vec2d target_off = center_for(head);
|
||||
ghost_xf = primary_xf;
|
||||
ghost_xf.linear() = ghost_xf.linear()
|
||||
* Eigen::DiagonalMatrix<double, 3>(-1.0, 1.0, 1.0);
|
||||
ghost_xf.translation().y() += target_off.y() - primary_off.y();
|
||||
} else {
|
||||
const auto [pri_center, gantry] = resolve_centers(head);
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, head, role, gantry, pri_center);
|
||||
ghost_xf = head_xf * primary_xf;
|
||||
}
|
||||
ghost->set_instance_transformation(ghost_xf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
-17
@@ -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
|
||||
|
||||
@@ -130,12 +130,12 @@ TEST_CASE("imex_multicolor_block_reason — single-color prints never block", "[
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — IDEX (1 tool per gantry) blocks multi-color", "[IMEX]") {
|
||||
// Two physical heads, each on its own gantry: tools_per_gantry=1, primary=T0
|
||||
// on gantry 0, copy=T1 on gantry 1. Primary's gantry has only 1 active tool,
|
||||
// so there's no within-gantry toolchange path. Block.
|
||||
// on gantry 0, copy=T1 on gantry 1. Primary's gantry can never carry a Span
|
||||
// partner (only 1 tool slot), so multicolor in this mode is never declarable.
|
||||
auto pem = make_pem({0, 1});
|
||||
const std::string reason = imex_multicolor_block_reason("copy", "0:P,1:C", 1, {0, 1}, pem);
|
||||
REQUIRE_FALSE(reason.empty());
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry"));
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — single-gantry IMEX mode blocks multi-color", "[IMEX]") {
|
||||
@@ -153,20 +153,31 @@ TEST_CASE("imex_multicolor_block_reason — single-gantry IMEX mode blocks multi
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — IQEX 2-tool-active mode blocks multi-color", "[IMEX]") {
|
||||
// 2x2 IQEX, mode has T0 primary + T2 copy (one tool per gantry, different
|
||||
// gantries). Primary's gantry (gantry 0) has only T0 active → no within-gantry
|
||||
// swap target. Block.
|
||||
// gantries). No Span on primary's gantry → multicolor partner not declared. Block.
|
||||
auto pem = make_pem({0, 1, 2, 3});
|
||||
const std::string reason = imex_multicolor_block_reason("copy", "0:P,2:C", 2, {0, 2}, pem);
|
||||
REQUIRE_FALSE(reason.empty());
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry"));
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — IQEX 4-tool-active multi-color allowed", "[IMEX]") {
|
||||
// 2x2 IQEX, all four tools active in the mode: T0 primary + T1 also on gantry 0,
|
||||
// T2/T3 on gantry 1 mirroring. Primary's gantry has 2 tools active → within-
|
||||
// gantry toolchange topology is present. Used filaments {0, 1} both on gantry 0.
|
||||
TEST_CASE("imex_multicolor_block_reason — IQEX 4-tool independent copies block multi-color", "[IMEX]") {
|
||||
// 2x2 IQEX, T0:P,T1:C,T2:M,T3:M — user's real-world 4-independent-copies job.
|
||||
// No Span on primary's gantry: T1 is an independent copy, not a multicolor
|
||||
// partner, so multicolor here would be incoherent (T1 prints its own object,
|
||||
// it can't sync color changes with T0). Block.
|
||||
auto pem = make_pem({0, 1, 2, 3});
|
||||
REQUIRE(imex_multicolor_block_reason("copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem).empty());
|
||||
const std::string reason = imex_multicolor_block_reason("copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem);
|
||||
REQUIRE_FALSE(reason.empty());
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — IQEX paired-gantry multicolor allowed with Span", "[IMEX]") {
|
||||
// 2x2 IQEX, T0:P,T1:S,T2:M,T3:M — Span on T1 declares the within-gantry multicolor
|
||||
// partner; T2/T3 mirror with column-pairing T2↔T0, T3↔T1. The slaved gantry can
|
||||
// follow the primary's mid-print T0↔T1 toolchange because both colors live on the
|
||||
// same gantry. Allowed.
|
||||
auto pem = make_pem({0, 1, 2, 3});
|
||||
REQUIRE(imex_multicolor_block_reason("copy", "0:P,1:S,2:M,3:M", 2, {0, 1}, pem).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("imex_multicolor_block_reason — MMU lane sharing blocks multi-color", "[IMEX]") {
|
||||
@@ -537,3 +548,100 @@ TEST_CASE("resolve_filament_for_head — no routing returns -1 (ghost color fall
|
||||
std::map<int,int> override_on_5 = {{5, 7}};
|
||||
REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_imex_active_tools — Span role parsed from S suffix", "[IMEX]") {
|
||||
auto out = parse_imex_active_tools("0:P,1:S,2:M,3:M");
|
||||
REQUIRE(out.size() == 4);
|
||||
REQUIRE(out[0].first == 0);
|
||||
REQUIRE(out[0].second == ImexRole::Primary);
|
||||
REQUIRE(out[1].first == 1);
|
||||
REQUIRE(out[1].second == ImexRole::Span);
|
||||
REQUIRE(out[2].first == 2);
|
||||
REQUIRE(out[2].second == ImexRole::Mirror);
|
||||
REQUIRE(out[3].first == 3);
|
||||
REQUIRE(out[3].second == ImexRole::Mirror);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_imex_active_tools — Span suffix whitespace tolerant", "[IMEX]") {
|
||||
auto out = parse_imex_active_tools(" 0 : P , 1 : S ");
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[1].second == ImexRole::Span);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — paired-gantry mc-mirror aggregates", "[IMEX]") {
|
||||
// 2x2 IQEX, primary T0, T1 declared Span (multicolor partner on primary's gantry),
|
||||
// T2/T3 mirror with column-pairing T2↔T0 and T3↔T1.
|
||||
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:M,3:M", 2);
|
||||
REQUIRE(g.primary_phys == 0);
|
||||
REQUIRE(g.primary_gantry == 0);
|
||||
REQUIRE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
|
||||
REQUIRE(g.groups[0].gantry_index == 0);
|
||||
REQUIRE_FALSE(g.groups[0].aggregate); // primary's gantry never aggregates
|
||||
REQUIRE(g.groups[0].tools.size() == 2);
|
||||
|
||||
REQUIRE(g.groups[1].gantry_index == 1);
|
||||
REQUIRE(g.groups[1].aggregate);
|
||||
REQUIRE(g.groups[1].representative_phys == 2); // column-paired to primary T0
|
||||
REQUIRE(g.groups[1].representative_role == ImexRole::Mirror);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — 4 independent copies (no Span) stay per-tool", "[IMEX]") {
|
||||
// User's real-world 4-copy job. Same active_tools shape as the mc-mirror case but no
|
||||
// Span marker → each tool keeps its own ghost + zone. This is the disambiguation that
|
||||
// motivates the Span tile state.
|
||||
auto g = group_imex_active_tools_by_gantry("0:P,1:C,2:M,3:M", 2);
|
||||
REQUIRE_FALSE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
REQUIRE_FALSE(g.groups[0].aggregate);
|
||||
REQUIRE_FALSE(g.groups[1].aggregate);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — non-primary gantry with single tool stays per-tool", "[IMEX]") {
|
||||
// 2-tool mirror on 2x2 IQEX (T0 primary, T2 mirror) — even with Span elsewhere on
|
||||
// primary's gantry, a 1-tool non-primary gantry has nothing to aggregate.
|
||||
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:M", 2);
|
||||
REQUIRE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
REQUIRE(g.groups[1].gantry_index == 1);
|
||||
REQUIRE(g.groups[1].tools.size() == 1);
|
||||
REQUIRE_FALSE(g.groups[1].aggregate);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — mixed-role non-primary gantry falls back", "[IMEX]") {
|
||||
// T2:C, T3:M on the same non-primary gantry — user explicitly authored two distinct
|
||||
// topologies for that gantry. Aggregation would lose information; stay per-tool.
|
||||
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:C,3:M", 2);
|
||||
REQUIRE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
REQUIRE_FALSE(g.groups[1].aggregate);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — IDEX (tpg=1) never aggregates", "[IMEX]") {
|
||||
// IDEX has 1 tool per gantry by definition — primary's gantry has no Span partner,
|
||||
// and non-primary gantries each have 1 tool. Aggregation never triggers.
|
||||
auto g = group_imex_active_tools_by_gantry("0:P,1:M", 1);
|
||||
REQUIRE_FALSE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
REQUIRE_FALSE(g.groups[0].aggregate);
|
||||
REQUIRE_FALSE(g.groups[1].aggregate);
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — empty / no primary returns empty grouping", "[IMEX]") {
|
||||
auto g = group_imex_active_tools_by_gantry("", 2);
|
||||
REQUIRE(g.primary_phys == -1);
|
||||
REQUIRE(g.groups.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("group_imex_active_tools_by_gantry — column pairing picks correct representative", "[IMEX]") {
|
||||
// Primary at T1 (col=1, gantry=0). On gantry 1, the column-pair is T3 (col=1, gantry=1).
|
||||
// Representative for gantry 1 must be T3, not T2 — drives mirror geometry through
|
||||
// the column-paired tool's role.
|
||||
auto g = group_imex_active_tools_by_gantry("0:S,1:P,2:M,3:M", 2);
|
||||
REQUIRE(g.primary_phys == 1);
|
||||
REQUIRE(g.span_on_primary);
|
||||
REQUIRE(g.groups.size() == 2);
|
||||
REQUIRE(g.groups[1].gantry_index == 1);
|
||||
REQUIRE(g.groups[1].representative_phys == 3); // column-paired to primary T1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user