mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
feat(imex): plate ghost renderer + per-plate filament map
Replaces the plater-icon popover with colored transparent ghost copies
of primary-head instances on the plate, one per secondary active head
under its Copy/Mirror role transform. Left-click on a ghost opens a
compact filament picker popover for the ghost's head (MMU lane override).
Ghosts track the primary through drag/rotate/scale/mirror and invalidate
on mode or pem changes.
Key pieces:
IMEXHelpers -- imex_head_transform (Primary/Copy/Mirror), shared role
parser, per-head filament resolution with X-axis Mirror anchor.
PartPlate -- ghost state, volume rebuild on mode/map/object mutation,
primary_origin plumbed for Mirror reflection across the primary-row
gantry plane.
GLCanvas3D -- ghost rendering with per-head filament color and
translucent blending; picking routed via volume composite id.
Plater -- ghost click + tooltip; plater icon left-click always cycles.
IMEXFilamentPickerPopover -- BitmapComboBox row for one secondary head,
writes imex_head_filament_map on selection.
bbs_3mf -- round-trip the per-plate imex_head_filament_map option.
PrintConfig -- add imex_head_filament_map as a plate option.
MMU/AFC routing for parallel modes relies on the printer profile's
physical_extruder_map (see prior commit for authoring format). Primary-
row heads and their per-plate filament overrides are resolved through
that map, so PA and temperature emission address the correct physical
extruder when multiple logical slots share one carriage.
Tests: IMEXHelpers coverage for Primary/Copy/Mirror transforms
including a 2x2 off-row regression guard for the X-axis reflection fix.
This commit is contained in:
@@ -274,6 +274,8 @@ set(lisbslic3r_sources
|
||||
Geometry/VoronoiUtils.cpp
|
||||
Geometry/VoronoiUtils.hpp
|
||||
Geometry/VoronoiVisualUtils.hpp
|
||||
IMEXHelpers.cpp
|
||||
IMEXHelpers.hpp
|
||||
Int128.hpp
|
||||
KDTreeIndirect.hpp
|
||||
Layer.cpp
|
||||
|
||||
@@ -335,6 +335,7 @@ static constexpr const char* OTHER_LAYERS_PRINT_SEQUENCE_ATTR = "other_layers_pr
|
||||
static constexpr const char* OTHER_LAYERS_PRINT_SEQUENCE_NUMS_ATTR = "other_layers_print_sequence_nums";
|
||||
static constexpr const char* SPIRAL_VASE_MODE = "spiral_mode";
|
||||
static constexpr const char* IMEX_PARALLEL_MODE_ATTR = "imex_parallel_mode";
|
||||
static constexpr const char* IMEX_HEAD_FILAMENT_MAP_ATTR = "imex_head_filament_map";
|
||||
static constexpr const char* FILAMENT_MAP_MODE_ATTR = "filament_map_mode";
|
||||
static constexpr const char* FILAMENT_MAP_ATTR = "filament_maps";
|
||||
static constexpr const char* LIMIT_FILAMENT_MAP_ATTR = "limit_filament_maps";
|
||||
@@ -4300,6 +4301,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
else if (key == IMEX_PARALLEL_MODE_ATTR) {
|
||||
m_curr_plater->config.set_key_value("imex_parallel_mode", new ConfigOptionString(value));
|
||||
}
|
||||
else if (key == IMEX_HEAD_FILAMENT_MAP_ATTR) {
|
||||
m_curr_plater->config.set_key_value("imex_head_filament_map", new ConfigOptionString(value));
|
||||
}
|
||||
else if (key == FILAMENT_MAP_MODE_ATTR)
|
||||
{
|
||||
FilamentMapMode map_mode = FilamentMapMode::fmmAutoForFlush;
|
||||
@@ -7790,6 +7794,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
if (imex_mode_opt && !imex_mode_opt->value.empty() && imex_mode_opt->value != "primary")
|
||||
stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << IMEX_PARALLEL_MODE_ATTR << "\" " << VALUE_ATTR << "=\"" << imex_mode_opt->value << "\"/>\n";
|
||||
}
|
||||
{
|
||||
auto* imex_hfm_opt = plate_data->config.option<ConfigOptionString>("imex_head_filament_map");
|
||||
if (imex_hfm_opt && !imex_hfm_opt->value.empty())
|
||||
stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << IMEX_HEAD_FILAMENT_MAP_ATTR << "\" " << VALUE_ATTR << "=\"" << imex_hfm_opt->value << "\"/>\n";
|
||||
}
|
||||
|
||||
//filament map related
|
||||
ConfigOption* filament_map_mode_opt = plate_data->config.option("filament_map_mode");
|
||||
|
||||
+40
-11
@@ -4,6 +4,7 @@
|
||||
#include "PrintConfig.hpp"
|
||||
#include "libslic3r.h"
|
||||
#include "I18N.hpp"
|
||||
#include "IMEXHelpers.hpp"
|
||||
#include "GCode.hpp"
|
||||
#include "Exception.hpp"
|
||||
#include "ExtrusionEntity.hpp"
|
||||
@@ -2392,10 +2393,12 @@ static BambuBedType to_bambu_bed_type(BedType type)
|
||||
}
|
||||
|
||||
// Orca IMEX: Returns the tool indices active in the current IMEX mode.
|
||||
// In copy/mirror parallel modes, secondary carriages (T1-T3) never receive tool-change
|
||||
// In copy/mirror parallel modes, secondary carriages never receive tool-change
|
||||
// commands — the firmware mirrors the primary's moves — so they don't appear in
|
||||
// tool_ordering.all_extruders(). This helper parses imex_mode_active_tools to enumerate them.
|
||||
// Format: "0:P,1:C,2:M,3:M" — only the leading integer index is used.
|
||||
// Indices are PHYSICAL T-indices. Callers that need a filament-slot (for PA / temp
|
||||
// lookups) must resolve via first_filament_for_physical_head or resolve_filament_for_head.
|
||||
static std::vector<int> get_imex_active_tools(const Print& print)
|
||||
{
|
||||
std::vector<int> active_tools;
|
||||
@@ -3095,10 +3098,13 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
|
||||
int imex_active_mode_index = 0;
|
||||
std::string imex_active_mode_gcode;
|
||||
m_imex_parallel_mode.clear();
|
||||
m_imex_head_filament_map.clear();
|
||||
if (print.config().is_imex.value && !print.objects().empty()) {
|
||||
const std::string& raw = print.objects().front()->config().imex_parallel_mode.value;
|
||||
imex_active_mode = raw.empty() ? "primary" : raw;
|
||||
m_imex_parallel_mode = imex_active_mode;
|
||||
m_imex_head_filament_map = parse_imex_head_filament_map(
|
||||
print.objects().front()->config().imex_head_filament_map.value);
|
||||
const auto& mode_names = print.config().imex_mode_names.values;
|
||||
const auto& mode_gcodes = print.config().imex_mode_gcodes.values;
|
||||
for (size_t i = 0; i < mode_names.size(); ++i) {
|
||||
@@ -3227,13 +3233,22 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
|
||||
// In primary mode, regular tool-change PA handles each tool as it becomes active.
|
||||
// In parallel modes no tool changes occur, so every carriage must be addressed
|
||||
// explicitly here before printing starts.
|
||||
if (!m_imex_parallel_mode.empty() && m_imex_parallel_mode != "primary") {
|
||||
if (!m_imex_parallel_mode.empty() && m_imex_parallel_mode != "primary"
|
||||
&& !m_config.physical_extruder_map.values.empty()) {
|
||||
// initial_physical: pem-translate the print's initial logical extruder so the
|
||||
// loop can skip the primary head (which emitted PA via the normal path).
|
||||
// Then pem-invert each active physical head back to its first routed filament
|
||||
// for the PA setting lookup. Guarded on non-empty pem above.
|
||||
const int initial_physical = m_config.physical_extruder_map.get_at((int)initial_extruder_id);
|
||||
for (int tool_idx : get_imex_active_tools(print)) {
|
||||
if (tool_idx == (int)initial_extruder_id) continue;
|
||||
if (!print.config().enable_pressure_advance.get_at(tool_idx)) continue;
|
||||
if (tool_idx == initial_physical) continue;
|
||||
const int logical = resolve_filament_for_head(
|
||||
m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx);
|
||||
if (logical < 0) continue;
|
||||
if (!print.config().enable_pressure_advance.get_at(logical)) continue;
|
||||
file.write(m_writer.set_pressure_advance(
|
||||
print.config().pressure_advance.get_at(tool_idx),
|
||||
m_config.physical_extruder_map.get_at(tool_idx)));
|
||||
print.config().pressure_advance.get_at(logical),
|
||||
tool_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4692,13 +4707,23 @@ LayerResult GCode::process_layer(
|
||||
// All active tools need explicit temps — none receive tool-change commands,
|
||||
// so we can't rely on the condition used for non-IMEX (temp != initial_layer_temp).
|
||||
// A tool whose initial and regular temps are the same still needs to be set here.
|
||||
// Skip the primary head: its filament is governed by the object sidebar and
|
||||
// the standard per-extruder temp path already addresses it (matching the PA
|
||||
// emission site above). `tool_idx` is physical; resolve to a filament slot
|
||||
// via pem inversion for temp lookup.
|
||||
const int num_nozzles = (int)print.config().nozzle_temperature.values.size();
|
||||
const int initial_physical = m_config.physical_extruder_map.values.empty()
|
||||
? -1
|
||||
: m_config.physical_extruder_map.get_at((int)first_extruder_id);
|
||||
for (int tool_idx : get_imex_active_tools(print)) {
|
||||
if (tool_idx >= num_nozzles) continue;
|
||||
int temperature = print.config().nozzle_temperature.values[tool_idx];
|
||||
if (tool_idx == initial_physical) continue;
|
||||
const int logical = resolve_filament_for_head(
|
||||
m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx);
|
||||
if (logical < 0 || logical >= num_nozzles) continue;
|
||||
int temperature = print.config().nozzle_temperature.values[logical];
|
||||
if (temperature > 0)
|
||||
gcode += GCodeWriter::set_temperature(temperature, m_writer.config.gcode_flavor, false,
|
||||
m_config.physical_extruder_map.get_at(tool_idx), "set IMEX tool temperature");
|
||||
tool_idx, "set IMEX tool temperature");
|
||||
}
|
||||
} else {
|
||||
for (const Extruder& extruder : m_writer.extruders()) {
|
||||
@@ -7564,8 +7589,11 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
|
||||
// In IMEX parallel modes each carriage needs an explicit tool address.
|
||||
// In primary mode (single active tool) regular tool changes handle PA
|
||||
// so no qualifier is needed — same as a non-IMEX printer.
|
||||
// Guard the pem lookup: PrintApply populates pem when printer_extruder_id
|
||||
// is set, but defense-in-depth prevents a throw from get_at on any
|
||||
// empty-pem path that might slip through in exotic profiles.
|
||||
const bool imex_parallel = !m_imex_parallel_mode.empty() && m_imex_parallel_mode != "primary";
|
||||
const int pa_tool = imex_parallel
|
||||
const int pa_tool = (imex_parallel && !m_config.physical_extruder_map.values.empty())
|
||||
? m_config.physical_extruder_map.get_at((int)new_filament_id)
|
||||
: -1;
|
||||
gcode += m_writer.set_pressure_advance(m_config.pressure_advance.get_at(new_filament_id), pa_tool);
|
||||
@@ -7867,8 +7895,9 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
|
||||
gcode += m_ooze_prevention.post_toolchange(*this);
|
||||
|
||||
if (m_config.enable_pressure_advance.get_at(new_filament_id)) {
|
||||
// Empty-pem guard mirrors the earlier PA site; get_at throws on empty values.
|
||||
const bool imex_parallel = !m_imex_parallel_mode.empty() && m_imex_parallel_mode != "primary";
|
||||
const int pa_tool = imex_parallel
|
||||
const int pa_tool = (imex_parallel && !m_config.physical_extruder_map.values.empty())
|
||||
? m_config.physical_extruder_map.get_at((int)new_filament_id)
|
||||
: -1;
|
||||
gcode += m_writer.set_pressure_advance(m_config.pressure_advance.get_at(new_filament_id), pa_tool);
|
||||
|
||||
@@ -596,6 +596,9 @@ private:
|
||||
// Set at the start of export. Empty string means non-IMEX or not yet set.
|
||||
// PA and temperature tool-qualification is only applied when this is not "primary".
|
||||
std::string m_imex_parallel_mode;
|
||||
// IMEX: parsed per-plate head→filament overrides (physical T-index → 1-based filament slot).
|
||||
// Cached from imex_head_filament_map at print start. Empty map means "fall back to pem".
|
||||
std::map<int,int> m_imex_head_filament_map;
|
||||
|
||||
std::unique_ptr<WipeTowerIntegration> m_wipe_tower;
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical)
|
||||
{
|
||||
const auto& v = pem.values;
|
||||
if (v.empty())
|
||||
return (physical == 0) ? 0 : -1; // degenerate: treat as identity on head 0
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
if (v[i] == physical)
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool has_mmu(const ConfigOptionInts& pem)
|
||||
{
|
||||
if (pem.values.size() < 2)
|
||||
return false;
|
||||
std::unordered_set<int> seen;
|
||||
for (int pv : pem.values) {
|
||||
if (!seen.insert(pv).second)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool has_non_primary_mmu(const ConfigOptionInts& pem, int primary_physical)
|
||||
{
|
||||
if (pem.values.size() < 2)
|
||||
return false;
|
||||
std::unordered_set<int> seen;
|
||||
for (int pv : pem.values) {
|
||||
if (pv == primary_physical)
|
||||
continue;
|
||||
if (!seen.insert(pv).second)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string& active_tools_for_mode)
|
||||
{
|
||||
std::vector<std::pair<int, ImexRole>> out;
|
||||
if (active_tools_for_mode.empty()) return out;
|
||||
std::istringstream ss(active_tools_for_mode);
|
||||
std::string tok;
|
||||
while (std::getline(ss, tok, ',')) {
|
||||
tok.erase(std::remove_if(tok.begin(), tok.end(), ::isspace), tok.end());
|
||||
if (tok.empty()) continue;
|
||||
const auto colon = tok.find(':');
|
||||
const std::string idx_str = (colon == std::string::npos) ? tok : tok.substr(0, colon);
|
||||
int phys = -1;
|
||||
try { phys = std::stoi(idx_str); } catch (...) { continue; }
|
||||
if (phys < 0) continue;
|
||||
ImexRole role = ImexRole::Copy;
|
||||
if (colon != std::string::npos) {
|
||||
const std::string r = tok.substr(colon + 1);
|
||||
if (r == "P") role = ImexRole::Primary;
|
||||
else if (r == "M") role = ImexRole::Mirror;
|
||||
// "C" and anything else → Copy.
|
||||
}
|
||||
out.emplace_back(phys, role);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int imex_primary_tool_for_mode(const std::string& active_tools_for_mode)
|
||||
{
|
||||
if (active_tools_for_mode.empty())
|
||||
return -1;
|
||||
std::istringstream ss(active_tools_for_mode);
|
||||
std::string token;
|
||||
int first_bare = -1;
|
||||
while (std::getline(ss, token, ',')) {
|
||||
token.erase(std::remove_if(token.begin(), token.end(), ::isspace), token.end());
|
||||
if (token.empty())
|
||||
continue;
|
||||
const auto colon = token.find(':');
|
||||
const std::string idx_str = (colon == std::string::npos) ? token : token.substr(0, colon);
|
||||
int idx = -1;
|
||||
try {
|
||||
idx = std::stoi(idx_str);
|
||||
} catch (...) {
|
||||
continue;
|
||||
}
|
||||
if (idx < 0)
|
||||
continue;
|
||||
if (colon == std::string::npos) {
|
||||
// Backwards-compat: a bare index is Primary.
|
||||
if (first_bare < 0)
|
||||
first_bare = idx;
|
||||
continue;
|
||||
}
|
||||
const std::string role = token.substr(colon + 1);
|
||||
if (role == "P")
|
||||
return idx;
|
||||
}
|
||||
return first_bare;
|
||||
}
|
||||
|
||||
std::map<int,int> parse_imex_head_filament_map(const std::string& s)
|
||||
{
|
||||
std::map<int,int> result;
|
||||
if (s.empty()) return result;
|
||||
std::istringstream ss(s);
|
||||
std::string token;
|
||||
while (std::getline(ss, token, ',')) {
|
||||
token.erase(std::remove_if(token.begin(), token.end(), ::isspace), token.end());
|
||||
if (token.empty()) continue;
|
||||
auto colon = token.find(':');
|
||||
if (colon == std::string::npos) continue;
|
||||
try {
|
||||
int phys = std::stoi(token.substr(0, colon));
|
||||
int slot = std::stoi(token.substr(colon + 1));
|
||||
if (phys >= 0 && slot >= 1)
|
||||
result[phys] = slot;
|
||||
} catch (...) {}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int resolve_filament_for_head(const std::map<int,int>& plate_map,
|
||||
const ConfigOptionInts& pem,
|
||||
int physical)
|
||||
{
|
||||
auto it = plate_map.find(physical);
|
||||
if (it != plate_map.end()) {
|
||||
const int zero_based = it->second - 1;
|
||||
if (zero_based >= 0)
|
||||
return zero_based;
|
||||
}
|
||||
return first_filament_for_physical_head(pem, physical);
|
||||
}
|
||||
|
||||
Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role,
|
||||
const Vec2d& gantry_offset,
|
||||
const Vec3d& primary_origin)
|
||||
{
|
||||
switch (role) {
|
||||
case ImexRole::Primary:
|
||||
return Transform3d::Identity();
|
||||
case ImexRole::Copy:
|
||||
return Eigen::Translation3d(gantry_offset.x(), gantry_offset.y(), 0.0)
|
||||
* Transform3d::Identity();
|
||||
case ImexRole::Mirror: {
|
||||
const double len2 = gantry_offset.squaredNorm();
|
||||
if (len2 < 1e-12)
|
||||
return Transform3d::Identity();
|
||||
// Reflection plane normal is the primary-row gantry axis (X for all
|
||||
// current IMEX printers), not gantry_offset.normalized(). An off-row
|
||||
// Mirror target (e.g. T3 on a 2x2 where primary is T0) has a diagonal
|
||||
// gantry_offset; reflecting across that diagonal plane rotates the
|
||||
// ghost ~45° in plan view, which visually reads as the object laying
|
||||
// on its side. All Mirror ghosts must flip across the same plane
|
||||
// (the one the primary-row Primary↔Mirror pair defines), so that
|
||||
// off-row mirrors look like their on-row counterparts, just placed
|
||||
// at the off-row position.
|
||||
// Formula: head_xf = T(gantry) * T(p) * Reflect(n) * T(-p)
|
||||
// .linear() = Reflect(n) = I - 2 n n^T
|
||||
// .translation() = gantry + (I - Reflect) * p = gantry + 2 n n^T p
|
||||
// TODO: if a future IMEX printer has Y-oriented gantries, lift this
|
||||
// to a caller-supplied axis.
|
||||
const Vec3d n(1.0, 0.0, 0.0);
|
||||
Eigen::Matrix3d I3 = Eigen::Matrix3d::Identity();
|
||||
Eigen::Matrix3d nnT = n * n.transpose();
|
||||
Eigen::Matrix3d L = I3 - 2.0 * nnT;
|
||||
Vec3d t = Vec3d(gantry_offset.x(), gantry_offset.y(), 0.0)
|
||||
+ (I3 - L) * primary_origin;
|
||||
Transform3d out = Transform3d::Identity();
|
||||
out.linear() = L;
|
||||
out.translation() = t;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return Transform3d::Identity();
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <Eigen/Geometry>
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Returns the lowest 0-based logical filament index L such that pem[L] == physical.
|
||||
// Returns -1 if no filament routes to `physical`.
|
||||
// Degenerate case: an empty pem returns 0 when `physical == 0` (identity-on-head-0
|
||||
// fallback for printers that never configured a pem) and -1 otherwise.
|
||||
// With identity pem, the caller sees logical == physical behavior because
|
||||
// position L holds value L.
|
||||
int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical);
|
||||
|
||||
// Returns true if the printer has at least one physical head fed by >= 2 logical
|
||||
// filaments (i.e., an MMU/AFC is present on some head). Drives the plater's
|
||||
// click-handler branch: true -> open popover; false -> retain cycle-through.
|
||||
bool has_mmu(const ConfigOptionInts& pem);
|
||||
|
||||
// Returns true if any physical head OTHER than `primary_physical` is fed by
|
||||
// >= 2 logical filaments. Primary is the head whose filament is already
|
||||
// controlled by the left sidebar's object->filament assignment, so an MMU
|
||||
// there needs no popover row; only non-primary MMU heads do.
|
||||
// Returns false when pem is empty or size < 2.
|
||||
bool has_non_primary_mmu(const ConfigOptionInts& pem, int primary_physical);
|
||||
|
||||
// Parses one mode's entry from `imex_mode_active_tools` and returns the
|
||||
// 0-based physical T-index carrying the `:P` (Primary) role marker.
|
||||
// Accepts two forms:
|
||||
// "0:P,1:C,2:C" (explicit role suffix)
|
||||
// "0" (backwards-compat: a bare index == Primary)
|
||||
// Returns -1 on empty/malformed input or if no primary is found.
|
||||
// The caller is responsible for indexing into imex_mode_active_tools by mode.
|
||||
int imex_primary_tool_for_mode(const std::string& active_tools_for_mode);
|
||||
|
||||
// Parses the "phys:slot,phys:slot" serialization of imex_head_filament_map.
|
||||
// Keys are physical T-indices (0-based); values are 1-based filament slots.
|
||||
// Returns empty map on empty/malformed input.
|
||||
std::map<int,int> parse_imex_head_filament_map(const std::string& s);
|
||||
|
||||
// Resolves which 0-based logical filament to use for a physical head.
|
||||
// 1. If the plate map overrides `physical`, returns (slot - 1) — 1-based → 0-based.
|
||||
// 2. Else falls back to first_filament_for_physical_head(pem, physical).
|
||||
// Returns -1 if neither source yields a valid filament.
|
||||
int resolve_filament_for_head(const std::map<int,int>& plate_map,
|
||||
const ConfigOptionInts& pem,
|
||||
int physical);
|
||||
|
||||
enum class ImexRole { Primary, Copy, Mirror };
|
||||
|
||||
// Parses `imex_mode_active_tools[mode]` into a list of (physical_head, role) pairs.
|
||||
// Accepted token forms (comma-separated, whitespace-tolerant):
|
||||
// "phys" — bare index, role defaults to Copy
|
||||
// "phys:P" — Primary
|
||||
// "phys:C" — Copy
|
||||
// "phys:M" — Mirror
|
||||
// "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`,
|
||||
// which separately scans for a Primary; callers needing the primary index should
|
||||
// use that helper. This parser is for call sites that already have the primary
|
||||
// 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);
|
||||
|
||||
// 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).
|
||||
// `primary_origin` = world-space translation of the primary instance (use its matrix's
|
||||
// translation column). Only consulted for Mirror; Copy/Primary ignore it.
|
||||
// Copy: pure translation by gantry_offset. Ghost tracks primary 1:1 during drag.
|
||||
// Mirror: translate by gantry_offset (Copy-style placement AND motion), then flip the
|
||||
// ghost's geometry about a plane through primary_origin whose normal is the
|
||||
// primary-row gantry axis (X for all current IMEX printers), NOT the gantry_offset
|
||||
// direction. This keeps off-row Mirror ghosts (e.g. T3 on a 2x2) reflected across
|
||||
// the same plane as on-row mirrors (e.g. T1), just placed at the off-row position.
|
||||
// Zero-length gantry_offset degenerates to identity.
|
||||
// Primary: identity.
|
||||
Transform3d imex_head_transform(int primary, int target, ImexRole role,
|
||||
const Vec2d& gantry_offset,
|
||||
const Vec3d& primary_origin = Vec3d::Zero());
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -963,6 +963,7 @@ static std::vector<std::string> s_Preset_print_options {
|
||||
"interlocking_beam", "interlocking_orientation", "interlocking_beam_layer_count", "interlocking_depth", "interlocking_boundary_avoidance", "interlocking_beam_width","calib_flowrate_topinfill_special_order",
|
||||
// IDEX/IQEX parallel print mode (per-print selection)
|
||||
"imex_parallel_mode",
|
||||
"imex_head_filament_map",
|
||||
};
|
||||
|
||||
static std::vector<std::string> s_Preset_filament_options {/*"filament_colour", */ "default_filament_colour", "required_nozzle_HRC", "filament_diameter", "pellet_flow_coefficient", "volumetric_speed_coefficients", "filament_type",
|
||||
|
||||
@@ -49,6 +49,14 @@ static std::vector<std::string> s_project_options {
|
||||
"nozzle_volume_type",
|
||||
"filament_map_mode",
|
||||
"filament_map"
|
||||
// physical_extruder_map intentionally NOT here: it's owned by the printer
|
||||
// preset (s_Preset_printer_options). Listing it project-scoped caused
|
||||
// project_config's default [0] to clobber the preset's authored value
|
||||
// (e.g. AFC-shaped [0,1,1,1,1]) during full_fff_config() merge, and the
|
||||
// clobbered value then rode into saved 3mfs and back into the edited
|
||||
// preset on reload. MoonrakerPrinterAgent's runtime set_key_value still
|
||||
// works — it creates the option on project_config on demand without
|
||||
// needing it pre-initialized here.
|
||||
};
|
||||
|
||||
//Orca: add custom as default
|
||||
|
||||
@@ -5555,6 +5555,13 @@ void PrintConfigDef::init_fff_params()
|
||||
def->mode = comAdvanced;
|
||||
def->set_default_value(new ConfigOptionString("primary"));
|
||||
|
||||
def = this->add("imex_head_filament_map", coString);
|
||||
def->label = L("IMEX head filament map");
|
||||
def->tooltip = L("Per-plate override mapping physical heads to filament slots "
|
||||
"(1-based). Empty means use pem-inversion defaults.");
|
||||
def->mode = comDevelop;
|
||||
def->set_default_value(new ConfigOptionString(""));
|
||||
|
||||
def = this->add("imex_mode_names", coStrings);
|
||||
def->label = L("IDEX/IQEX Mode Names");
|
||||
def->tooltip = L("Display names for each user-defined IDEX/IQEX parallel print mode.");
|
||||
|
||||
@@ -1037,6 +1037,10 @@ PRINT_CONFIG_CLASS_DEFINE(
|
||||
|
||||
// IDEX/IQEX parallel print mode (per-print selection, stores mode name or "primary")
|
||||
((ConfigOptionString, imex_parallel_mode))
|
||||
// Per-plate head→filament override for MMU-equipped printers.
|
||||
// Serialized as compact "phys:slot,phys:slot" (1-based filament slots, matches UI).
|
||||
// Empty string means "use first_filament_for_physical_head defaults everywhere".
|
||||
((ConfigOptionString, imex_head_filament_map))
|
||||
)
|
||||
|
||||
// This object is mapped to Perl as Slic3r::Config::PrintRegion.
|
||||
|
||||
@@ -240,6 +240,8 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/FilamentMapDialog.hpp
|
||||
GUI/IconManager.cpp
|
||||
GUI/IconManager.hpp
|
||||
GUI/IMEXFilamentPickerPopover.cpp
|
||||
GUI/IMEXFilamentPickerPopover.hpp
|
||||
GUI/ImageGrid.cpp
|
||||
GUI/ImageGrid.h
|
||||
GUI/ImGuiWrapper.cpp
|
||||
|
||||
@@ -2169,11 +2169,19 @@ void GLCanvas3D::render(bool only_init)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Suppress the regular object-name/toolbar tooltip while hovering an IMEX
|
||||
// ghost; the swatch overlay below stands in for it.
|
||||
if (m_hover_ghost_head >= 0)
|
||||
tooltip.clear();
|
||||
|
||||
set_tooltip(tooltip);
|
||||
|
||||
if (m_tooltip_enabled)
|
||||
m_tooltip.render(m_mouse.position, *this);
|
||||
|
||||
// IMEX ghost hover overlay: layered on top of the normal tooltip pass.
|
||||
_render_imex_ghost_tooltip();
|
||||
|
||||
wxGetApp().plater()->get_mouse3d_controller().render_settings_dialog(*this);
|
||||
|
||||
if (m_canvas_type != ECanvasType::CanvasAssembleView) {
|
||||
@@ -4460,6 +4468,8 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt)
|
||||
TransformationType trafo_type;
|
||||
trafo_type.set_relative();
|
||||
m_selection.translate(cur_pos - m_mouse.drag.start_position_3D, trafo_type);
|
||||
// Ghost transforms refresh from _render_imex_ghosts via the live GLVolume
|
||||
// lookup, so no explicit update call is needed here.
|
||||
if (current_printer_technology() == ptFFF && (fff_print()->config().print_sequence == PrintSequence::ByObject))
|
||||
update_sequential_clearance();
|
||||
// BBS
|
||||
@@ -4617,6 +4627,13 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt)
|
||||
|
||||
m_rectangle_selection.stop_dragging();
|
||||
}
|
||||
else if (evt.LeftUp() && !m_mouse.dragging && m_hover_ghost_head >= 0) {
|
||||
// IMEX ghost click: dispatch to Plater (filament picker). The else-if chain
|
||||
// already prevents deselect/plate-select from firing on the same event; we
|
||||
// fall through to mouse_up_cleanup() below so mouse capture and drag state
|
||||
// get reset like every other branch in this chain.
|
||||
wxGetApp().plater()->on_imex_ghost_click(m_hover_ghost_head);
|
||||
}
|
||||
else if (evt.LeftUp() && !m_mouse.ignore_left_up && !m_mouse.dragging && m_hover_volume_idxs.empty() && m_hover_plate_idxs.empty() && !is_layers_editing_enabled()) {
|
||||
// deselect and propagate event through callback
|
||||
if (!evt.ShiftDown() && (!any_gizmo_active || !evt.CmdDown()) && m_picking_enabled)
|
||||
@@ -4956,6 +4973,10 @@ void GLCanvas3D::do_move(const std::string& snapshot_type)
|
||||
|
||||
reset_sequential_print_clearance();
|
||||
|
||||
// IMEX: selection commit may have moved/added/removed objects — ghost cache key
|
||||
// doesn't encode per-instance transforms, so force a full rebuild on next render.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5058,6 +5079,9 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type)
|
||||
if (!done.empty())
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_ROTATED));
|
||||
|
||||
// IMEX: rotate changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5150,6 +5174,9 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type)
|
||||
if (!done.empty())
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_SCALED));
|
||||
|
||||
// IMEX: scale changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5261,6 +5288,9 @@ void GLCanvas3D::do_mirror(const std::string& snapshot_type)
|
||||
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS));
|
||||
|
||||
// IMEX: mirror changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -7164,6 +7194,11 @@ void GLCanvas3D::_picking_pass()
|
||||
|
||||
_update_volumes_hover_state();
|
||||
|
||||
// IMEX ghost picking runs unconditionally after the normal pass: ghosts visually
|
||||
// occlude the main volumes, so their tooltip/click handling must fire even when a
|
||||
// regular volume hit also occurred underneath.
|
||||
_picking_pass_imex_ghosts();
|
||||
|
||||
#if ENABLE_RAYCAST_PICKING_DEBUG
|
||||
ImGuiWrapper& imgui = *wxGetApp().imgui();
|
||||
imgui.begin(std::string("Hit result"), ImGuiWindowFlags_AlwaysAutoResize);
|
||||
@@ -7264,6 +7299,149 @@ void GLCanvas3D::_picking_pass()
|
||||
#endif // ENABLE_RAYCAST_PICKING_DEBUG
|
||||
}
|
||||
|
||||
void GLCanvas3D::_picking_pass_imex_ghosts()
|
||||
{
|
||||
m_hover_ghost_head = -1;
|
||||
m_hover_ghost_plate = -1;
|
||||
|
||||
if (!m_picking_enabled || m_mouse.dragging || m_mouse.position == Vec2d(DBL_MAX, DBL_MAX) || m_gizmos.is_dragging())
|
||||
return;
|
||||
|
||||
// Build a world-space ray from the mouse: mouse_ray(pos) returns the near/far
|
||||
// world-space points, so direction is b - a.
|
||||
const Linef3 ray = mouse_ray(Point(static_cast<coord_t>(m_mouse.position.x()),
|
||||
static_cast<coord_t>(m_mouse.position.y())));
|
||||
const Vec3d ray_origin = ray.a;
|
||||
const Vec3d ray_dir = ray.b - ray.a;
|
||||
if (ray_dir.squaredNorm() == 0.0)
|
||||
return;
|
||||
|
||||
// Standard slab ray-vs-AABB test. Accept a hit when the nearest plane entry is
|
||||
// closer than the farthest plane exit and the exit is in front of the origin.
|
||||
auto ray_hits_bbox = [](const Vec3d& o, const Vec3d& d, const BoundingBoxf3& bb) -> bool {
|
||||
double tmin = -std::numeric_limits<double>::infinity();
|
||||
double tmax = std::numeric_limits<double>::infinity();
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (std::abs(d[i]) < 1e-12) {
|
||||
if (o[i] < bb.min[i] || o[i] > bb.max[i])
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
double t1 = (bb.min[i] - o[i]) / d[i];
|
||||
double t2 = (bb.max[i] - o[i]) / d[i];
|
||||
if (t1 > t2) std::swap(t1, t2);
|
||||
tmin = std::max(tmin, t1);
|
||||
tmax = std::min(tmax, t2);
|
||||
if (tmin > tmax) return false;
|
||||
}
|
||||
}
|
||||
return tmax >= 0.0;
|
||||
};
|
||||
|
||||
PartPlateList& ppl = wxGetApp().plater()->get_partplate_list();
|
||||
for (int pi = 0; pi < ppl.get_plate_count(); ++pi) {
|
||||
PartPlate* plate = ppl.get_plate(pi);
|
||||
if (!plate) continue;
|
||||
const auto& ghosts = plate->get_imex_ghost_volumes();
|
||||
if (ghosts.empty()) continue;
|
||||
for (const auto& g : ghosts) {
|
||||
if (!g || !g->is_active || !g->picking) continue;
|
||||
const BoundingBoxf3 bbox = g->transformed_bounding_box();
|
||||
if (ray_hits_bbox(ray_origin, ray_dir, bbox)) {
|
||||
m_hover_ghost_head = PartPlate::imex_ghost_head_from_composite_id(g->composite_id.object_id);
|
||||
m_hover_ghost_plate = pi;
|
||||
return; // first hit wins
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GLCanvas3D::_render_imex_ghosts()
|
||||
{
|
||||
// Called from inside _render_objects' Transparent branch while the gouraud
|
||||
// shader is bound and globals (z_far/z_near/z_range/clipping_plane) are set.
|
||||
// GLVolume::render() only binds its mesh, so we must set the per-volume
|
||||
// matrices AND uniform_color that GLVolumeCollection::render would normally
|
||||
// set; otherwise ghosts pick up whatever the last main volume left behind.
|
||||
GLShaderProgram* shader = wxGetApp().get_current_shader();
|
||||
if (shader == nullptr)
|
||||
return;
|
||||
|
||||
// Primary-volume live transform lookup: during a gizmo drag the GLVolume's
|
||||
// instance_transformation is the source of truth (the ModelInstance matrix
|
||||
// only catches up on mouse-up). Returning it from here makes ghosts track
|
||||
// the drag every frame instead of snapping when the user releases.
|
||||
auto primary_live_xf = [this](int obj_idx, int inst_idx) -> std::optional<Transform3d> {
|
||||
for (const GLVolume* v : m_volumes.volumes) {
|
||||
if (!v) continue;
|
||||
if (v->composite_id.object_id == obj_idx &&
|
||||
v->composite_id.instance_id == inst_idx)
|
||||
return v->get_instance_transformation().get_matrix();
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
const Camera& camera = wxGetApp().plater()->get_camera();
|
||||
const Transform3d& view_matrix = camera.get_view_matrix();
|
||||
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
||||
// Ghosts live outside the build volume check; disable the partly-inside path.
|
||||
shader->set_uniform("print_volume.type", -1);
|
||||
shader->set_uniform("slope.actived", false);
|
||||
|
||||
PartPlateList& ppl = wxGetApp().plater()->get_partplate_list();
|
||||
for (int pi = 0; pi < ppl.get_plate_count(); ++pi) {
|
||||
PartPlate* plate = ppl.get_plate(pi);
|
||||
if (!plate) continue;
|
||||
// Refresh per-frame so ghost positions reflect the primary's live drag state.
|
||||
plate->update_imex_ghost_transforms(primary_live_xf);
|
||||
const auto& ghosts = plate->get_imex_ghost_volumes();
|
||||
if (ghosts.empty()) continue;
|
||||
for (const auto& g : ghosts) {
|
||||
if (!g || !g->is_active) continue;
|
||||
const Transform3d model_matrix = g->world_matrix();
|
||||
shader->set_uniform("volume_world_matrix", model_matrix);
|
||||
shader->set_uniform("slope.volume_world_normal_matrix",
|
||||
static_cast<Matrix3f>(model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose().cast<float>()));
|
||||
shader->set_uniform("view_model_matrix", view_matrix * model_matrix);
|
||||
const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3)
|
||||
* model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
|
||||
shader->set_uniform("view_normal_matrix", view_normal_matrix);
|
||||
g->set_render_color();
|
||||
// GLModel::render() pushes its own data.color into uniform_color,
|
||||
// so we must stamp the ghost's color onto the model before render
|
||||
// or it draws black. Pattern matches 3DScene.cpp:1099.
|
||||
g->model.set_color(g->render_color);
|
||||
g->render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GLCanvas3D::_render_imex_ghost_tooltip()
|
||||
{
|
||||
if (m_hover_ghost_head < 0)
|
||||
return;
|
||||
|
||||
const auto t = wxGetApp().plater()->format_imex_ghost_tooltip(m_hover_ghost_head);
|
||||
|
||||
ImGuiWrapper& imgui = *wxGetApp().imgui();
|
||||
const Vec2i32 mouse = m_mouse.position.cast<int>();
|
||||
imgui.set_next_window_pos(float(mouse.x() + 16), float(mouse.y() + 16),
|
||||
ImGuiCond_Always, 0.0f, 0.0f);
|
||||
imgui.begin(std::string("##imex_ghost_tooltip"),
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoMouseInputs);
|
||||
// 14px color swatch + label on the same row.
|
||||
const ImVec4 col(t.swatch.r(), t.swatch.g(), t.swatch.b(), t.swatch.a());
|
||||
ImGui::ColorButton("##swatch", col,
|
||||
ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip,
|
||||
ImVec2(14, 14));
|
||||
ImGui::SameLine();
|
||||
imgui.text(t.label);
|
||||
imgui.end();
|
||||
}
|
||||
|
||||
void GLCanvas3D::_rectangular_selection_picking_pass()
|
||||
{
|
||||
m_gizmos.set_hover_id(-1);
|
||||
@@ -7685,6 +7863,9 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with
|
||||
}
|
||||
},
|
||||
partly_inside_enable);
|
||||
// IMEX ghosts are transparent and share the same shader/camera state;
|
||||
// render them right after the main transparent pass while the shader is still bound.
|
||||
_render_imex_ghosts();
|
||||
if (m_canvas_type == CanvasAssembleView && m_gizmos.m_assemble_view_data->model_objects_clipper()->get_position() > 0) {
|
||||
const GLGizmosManager& gm = get_gizmos_manager();
|
||||
shader->stop_using();
|
||||
|
||||
@@ -591,6 +591,9 @@ private:
|
||||
//BBS:add plate related logic
|
||||
mutable std::vector<int> m_hover_volume_idxs;
|
||||
std::vector<int> m_hover_plate_idxs;
|
||||
// IMEX ghost hover state (plate-owned transparent ghost volumes).
|
||||
int m_hover_ghost_head { -1 }; // physical head index, -1 when not hovering a ghost
|
||||
int m_hover_ghost_plate { -1 }; // plate index for the hovered ghost, -1 when none
|
||||
//BBS if explosion_ratio is changed, need to update volume bounding box
|
||||
mutable float m_explosion_ratio = 1.0;
|
||||
mutable Vec3d m_rotation_center{ 0.0, 0.0, 0.0};
|
||||
@@ -1067,6 +1070,8 @@ public:
|
||||
|
||||
int get_move_volume_id() const { return m_mouse.drag.move_volume_idx; }
|
||||
int get_first_hover_volume_idx() const { return m_hover_volume_idxs.empty() ? -1 : m_hover_volume_idxs.front(); }
|
||||
int get_hover_ghost_head() const { return m_hover_ghost_head; }
|
||||
int get_hover_ghost_plate() const { return m_hover_ghost_plate; }
|
||||
void set_selected_extruder(int extruder) { m_selected_extruder = extruder;}
|
||||
|
||||
class WipeTowerInfo {
|
||||
@@ -1230,12 +1235,18 @@ private:
|
||||
|
||||
void _picking_pass();
|
||||
void _rectangular_selection_picking_pass();
|
||||
// IMEX ghost picking (ray vs. ghost bbox). Runs at the end of _picking_pass.
|
||||
void _picking_pass_imex_ghosts();
|
||||
void _render_background();
|
||||
void _render_bed(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool show_axes);
|
||||
//BBS: add part plate related logic
|
||||
void _render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true);
|
||||
//BBS: add outline drawing logic
|
||||
void _render_objects(GLVolumeCollection::ERenderType type, bool with_outline = true);
|
||||
// IMEX ghost volumes owned by PartPlate; rendered in the transparent pass.
|
||||
void _render_imex_ghosts();
|
||||
// IMEX ghost hover tooltip: filament swatch + label drawn as an ImGui overlay.
|
||||
void _render_imex_ghost_tooltip();
|
||||
//BBS: GUI refactor: add canvas size as parameters
|
||||
void _render_gcode(int canvas_width, int canvas_height);
|
||||
//BBS: render a plane for assemble
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#include "slic3r/GUI/IMEXFilamentPickerPopover.hpp"
|
||||
|
||||
#include <wx/stattext.h>
|
||||
#include <wx/dcmemory.h>
|
||||
#include <wx/image.h>
|
||||
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "slic3r/GUI/BitmapComboBox.hpp"
|
||||
#include "slic3r/GUI/PartPlate.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/wxExtensions.hpp"
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
IMEXFilamentPickerPopover::IMEXFilamentPickerPopover(
|
||||
wxWindow* parent, PartPlate* plate,
|
||||
const ConfigOptionInts& pem, int physical_head,
|
||||
CommitCallback on_commit)
|
||||
: wxPopupTransientWindow(parent, wxBORDER_SIMPLE)
|
||||
, m_plate(plate)
|
||||
, m_pem(pem)
|
||||
, m_physical_head(physical_head)
|
||||
, m_on_commit(std::move(on_commit))
|
||||
{
|
||||
m_root_sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
SetSizer(m_root_sizer);
|
||||
|
||||
build_row();
|
||||
|
||||
m_root_sizer->SetSizeHints(this);
|
||||
SetClientSize(m_root_sizer->ComputeFittingClientSize(this));
|
||||
Layout();
|
||||
}
|
||||
|
||||
void IMEXFilamentPickerPopover::popup_at_cursor()
|
||||
{
|
||||
Position(wxGetMousePosition(), wxSize(0, 0));
|
||||
Popup();
|
||||
}
|
||||
|
||||
void IMEXFilamentPickerPopover::build_row()
|
||||
{
|
||||
m_root_sizer->Clear(true);
|
||||
|
||||
m_root_sizer->AddSpacer(10);
|
||||
|
||||
auto* label = new wxStaticText(this, wxID_ANY,
|
||||
wxString::Format("T%d", m_physical_head));
|
||||
m_root_sizer->Add(label, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 6);
|
||||
|
||||
m_root_sizer->AddSpacer(10);
|
||||
|
||||
std::vector<int> lane_logicals;
|
||||
for (size_t L = 0; L < m_pem.values.size(); ++L)
|
||||
if (m_pem.values[L] == m_physical_head)
|
||||
lane_logicals.push_back((int)L);
|
||||
|
||||
std::vector<wxBitmap*> bmps = get_extruder_color_icons(false /*thin_icon*/);
|
||||
|
||||
auto* choice = new BitmapComboBox(this, wxID_ANY, wxEmptyString,
|
||||
wxDefaultPosition, wxSize(140, -1),
|
||||
0, nullptr, wxCB_READONLY);
|
||||
const int left_pad_px = 8;
|
||||
auto pad_left = [&](const wxBitmap& src) -> wxBitmap {
|
||||
if (!src.IsOk()) return src;
|
||||
wxImage im(src.GetWidth() + left_pad_px, src.GetHeight());
|
||||
im.InitAlpha();
|
||||
unsigned char* a = im.GetAlpha();
|
||||
std::fill(a, a + im.GetWidth() * im.GetHeight(), (unsigned char)0);
|
||||
wxBitmap out(im);
|
||||
wxMemoryDC dc(out);
|
||||
dc.DrawBitmap(src, left_pad_px, 0, true);
|
||||
dc.SelectObject(wxNullBitmap);
|
||||
return out;
|
||||
};
|
||||
|
||||
for (int L : lane_logicals) {
|
||||
const wxString label = wxString::Format("filament %d", L + 1);
|
||||
if (L >= 0 && L < (int)bmps.size() && bmps[L] && bmps[L]->IsOk())
|
||||
choice->Append(label, pad_left(*bmps[L]));
|
||||
else
|
||||
choice->Append(label, wxNullBitmap);
|
||||
}
|
||||
|
||||
const auto map = m_plate->get_imex_head_filament_map();
|
||||
auto it = map.find(m_physical_head);
|
||||
int selected_slot_1based = (it != map.end())
|
||||
? it->second
|
||||
: (lane_logicals.empty() ? -1 : lane_logicals.front() + 1);
|
||||
|
||||
for (int i = 0; i < (int)lane_logicals.size(); ++i) {
|
||||
if (lane_logicals[i] + 1 == selected_slot_1based) {
|
||||
choice->SetSelection(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
choice->Bind(wxEVT_COMBOBOX, [this, lane_logicals, choice](wxCommandEvent&) {
|
||||
int sel = choice->GetSelection();
|
||||
if (sel < 0 || sel >= (int)lane_logicals.size()) return;
|
||||
on_filament_selected(lane_logicals[sel] + 1);
|
||||
Dismiss();
|
||||
});
|
||||
|
||||
m_root_sizer->Add(choice, 0, wxALIGN_CENTER_VERTICAL | wxALL, 6);
|
||||
m_root_sizer->AddSpacer(4);
|
||||
}
|
||||
|
||||
void IMEXFilamentPickerPopover::on_filament_selected(int slot_1_based)
|
||||
{
|
||||
auto map = m_plate->get_imex_head_filament_map();
|
||||
// Erase when the user reselects the pem default for this head, so the 3mf
|
||||
// doesn't carry no-op overrides (same invariant as PR3b).
|
||||
const int default_logical = first_filament_for_physical_head(m_pem, m_physical_head);
|
||||
if (default_logical >= 0 && slot_1_based == default_logical + 1)
|
||||
map.erase(m_physical_head);
|
||||
else
|
||||
map[m_physical_head] = slot_1_based;
|
||||
m_plate->set_imex_head_filament_map(map);
|
||||
if (m_on_commit) m_on_commit();
|
||||
}
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
@@ -0,0 +1,42 @@
|
||||
#ifndef slic3r_IMEXFilamentPickerPopover_hpp_
|
||||
#define slic3r_IMEXFilamentPickerPopover_hpp_
|
||||
|
||||
#include <wx/popupwin.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
namespace GUI {
|
||||
|
||||
class PartPlate;
|
||||
|
||||
class IMEXFilamentPickerPopover : public wxPopupTransientWindow
|
||||
{
|
||||
public:
|
||||
using CommitCallback = std::function<void()>;
|
||||
|
||||
IMEXFilamentPickerPopover(wxWindow* parent, PartPlate* plate,
|
||||
const ConfigOptionInts& pem, int physical_head,
|
||||
CommitCallback on_commit);
|
||||
|
||||
void popup_at_cursor();
|
||||
|
||||
private:
|
||||
void build_row();
|
||||
void on_filament_selected(int slot_1_based);
|
||||
|
||||
PartPlate* m_plate;
|
||||
const ConfigOptionInts& m_pem;
|
||||
int m_physical_head;
|
||||
CommitCallback m_on_commit;
|
||||
|
||||
wxSizer* m_root_sizer = nullptr;
|
||||
};
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <cstddef>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <numeric>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
@@ -24,6 +25,8 @@
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
#include "libslic3r/Tesselate.hpp"
|
||||
#include "libslic3r/GCode/ThumbnailData.hpp"
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#include "I18N.hpp"
|
||||
@@ -384,6 +387,7 @@ void PartPlate::set_imex_mode(const std::string& mode)
|
||||
}
|
||||
update_slice_result_valid_state(false);
|
||||
m_imex_zones_mode_cache = "\x01"; // force zone rebuild
|
||||
m_imex_ghost_cache_key = "\x01"; // force ghost rebuild
|
||||
}
|
||||
|
||||
void PartPlate::reset_imex_mode()
|
||||
@@ -391,6 +395,68 @@ void PartPlate::reset_imex_mode()
|
||||
m_config.erase("imex_parallel_mode");
|
||||
update_slice_result_valid_state(false);
|
||||
m_imex_zones_mode_cache = "\x01";
|
||||
m_imex_ghost_cache_key = "\x01";
|
||||
}
|
||||
|
||||
std::map<int,int> PartPlate::get_imex_head_filament_map() const
|
||||
{
|
||||
if (!m_config.has("imex_head_filament_map"))
|
||||
return {};
|
||||
auto* opt = m_config.option<ConfigOptionString>("imex_head_filament_map");
|
||||
if (!opt) return {};
|
||||
return parse_imex_head_filament_map(opt->value);
|
||||
}
|
||||
|
||||
void PartPlate::set_imex_head_filament_map(const std::map<int,int>& m)
|
||||
{
|
||||
if (m.empty()) {
|
||||
m_config.erase("imex_head_filament_map");
|
||||
} else {
|
||||
std::ostringstream os;
|
||||
bool first = true;
|
||||
for (const auto& [phys, slot] : m) {
|
||||
if (!first) os << ',';
|
||||
os << phys << ':' << slot;
|
||||
first = false;
|
||||
}
|
||||
m_config.set_key_value("imex_head_filament_map", new ConfigOptionString(os.str()));
|
||||
}
|
||||
update_slice_result_valid_state(false);
|
||||
m_imex_ghost_cache_key = "\x01"; // force ghost rebuild
|
||||
}
|
||||
|
||||
void PartPlate::reset_imex_head_filament_map()
|
||||
{
|
||||
m_config.erase("imex_head_filament_map");
|
||||
update_slice_result_valid_state(false);
|
||||
m_imex_ghost_cache_key = "\x01";
|
||||
}
|
||||
|
||||
ColorRGBA PartPlate::get_imex_head_filament_color(int physical_head) const
|
||||
{
|
||||
auto* pb = wxGetApp().preset_bundle;
|
||||
if (!pb)
|
||||
return GLVolume::UNPRINTABLE_COLOR;
|
||||
|
||||
const ConfigOptionInts* pem = pb->project_config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem || pem->values.size() < 2)
|
||||
pem = pb->printers.get_edited_preset().config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem)
|
||||
return GLVolume::UNPRINTABLE_COLOR;
|
||||
|
||||
const auto plate_map = get_imex_head_filament_map();
|
||||
const int logical = resolve_filament_for_head(plate_map, *pem, physical_head);
|
||||
if (logical < 0)
|
||||
return GLVolume::UNPRINTABLE_COLOR;
|
||||
|
||||
auto* colours = pb->project_config.option<ConfigOptionStrings>("filament_colour");
|
||||
if (!colours || logical >= (int)colours->values.size())
|
||||
return GLVolume::UNPRINTABLE_COLOR;
|
||||
|
||||
ColorRGBA rgba;
|
||||
if (!decode_color(colours->values[logical], rgba))
|
||||
return GLVolume::UNPRINTABLE_COLOR;
|
||||
return rgba;
|
||||
}
|
||||
|
||||
void PartPlate::set_spiral_vase_mode(bool spiral_mode, bool as_global)
|
||||
@@ -494,6 +560,8 @@ void PartPlate::calc_imex_zones()
|
||||
m_imex_collision_overlay.clear();
|
||||
m_imex_margin_overlay.clear();
|
||||
m_imex_primary_zone_box = std::nullopt;
|
||||
m_imex_head_zone_centers.clear();
|
||||
m_imex_primary_head = -1;
|
||||
|
||||
if (!wxGetApp().preset_bundle)
|
||||
return;
|
||||
@@ -553,8 +621,10 @@ void PartPlate::calc_imex_zones()
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "idx:P/C/M" format → map<tool_idx, state> (1=Primary, 2=Copy, 3=Mirror)
|
||||
// Parse "phys_idx:P/C/M" format → map<phys_idx, state> (1=Primary, 2=Copy, 3=Mirror)
|
||||
// Backwards compat: plain "idx" → Primary
|
||||
// Mode strings use physical T-indices directly (set on the IMEX config tab).
|
||||
// Filament routing is a per-plate concern handled separately via imex_head_filament_map.
|
||||
std::map<int,int> tool_states;
|
||||
{
|
||||
std::istringstream ss(active_tools_str);
|
||||
@@ -564,17 +634,17 @@ void PartPlate::calc_imex_zones()
|
||||
if (token.empty()) continue;
|
||||
try {
|
||||
auto colon = token.find(':');
|
||||
int idx, state = 1;
|
||||
int phys_idx, state = 1;
|
||||
if (colon != std::string::npos) {
|
||||
idx = std::stoi(token.substr(0, colon));
|
||||
phys_idx = std::stoi(token.substr(0, colon));
|
||||
char role = std::toupper((unsigned char)token[colon + 1]);
|
||||
if (role == 'C') state = 2;
|
||||
else if (role == 'M') state = 3;
|
||||
} else {
|
||||
idx = std::stoi(token);
|
||||
phys_idx = std::stoi(token);
|
||||
}
|
||||
if (idx >= 0 && idx < n_rows * n_cols)
|
||||
tool_states[idx] = state;
|
||||
if (phys_idx >= 0 && phys_idx < n_rows * n_cols)
|
||||
tool_states[phys_idx] = state;
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
@@ -586,7 +656,8 @@ void PartPlate::calc_imex_zones()
|
||||
|
||||
// Identify the Primary tool from the mode definition
|
||||
for (auto& [idx, state] : tool_states) {
|
||||
if (state == 1) { auto [c, r] = tool_to_phys(idx); pri_col = c; pri_row = r; break; }
|
||||
if (state == 1) { auto [c, r] = tool_to_phys(idx); pri_col = c; pri_row = r;
|
||||
m_imex_primary_head = idx; break; }
|
||||
}
|
||||
|
||||
// Separate copy and mirror secondary cells using physical coordinates
|
||||
@@ -634,6 +705,19 @@ void PartPlate::calc_imex_zones()
|
||||
int pri_col_k = col_to_zone.count(pri_col) ? col_to_zone[pri_col] : 0;
|
||||
int pri_row_k = row_to_zone.count(pri_row) ? row_to_zone[pri_row] : 0;
|
||||
|
||||
// Zone center per physical head — ghost placement consumes this so that
|
||||
// ghosts land in their own secondary zone instead of stacking on primary.
|
||||
// Every active tool (including primary) gets an entry; ghost offset math is
|
||||
// simply center[head] - center[primary].
|
||||
for (auto& [idx, state] : tool_states) {
|
||||
auto [c, r] = tool_to_phys(idx);
|
||||
int ck = col_to_zone.count(c) ? col_to_zone.at(c) : 0;
|
||||
int rk = row_to_zone.count(r) ? row_to_zone.at(r) : 0;
|
||||
m_imex_head_zone_centers[idx] = Vec2d(
|
||||
x_min + (ck + 0.5) * zone_w,
|
||||
y_min + (rk + 0.5) * zone_h);
|
||||
}
|
||||
|
||||
// Separation axes: row-sep = secondaries on a different gantry, col-sep = different column
|
||||
bool has_row_sep = false, has_col_sep = false;
|
||||
for (const auto& [sc, sr] : all_secondary) {
|
||||
@@ -897,6 +981,222 @@ void PartPlate::ensure_imex_zones()
|
||||
}
|
||||
}
|
||||
|
||||
std::string PartPlate::build_imex_ghost_cache_key() const
|
||||
{
|
||||
// Ghost shape depends on: mode topology (same inputs as zone key) + pem +
|
||||
// set of objects on plate + each primary instance's transform.
|
||||
std::string k = build_imex_cache_key();
|
||||
if (k.empty()) return ""; // ghost-off when zones-off
|
||||
|
||||
if (auto* pb = wxGetApp().preset_bundle) {
|
||||
const ConfigOptionInts* pem = pb->project_config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem || pem->values.size() < 2)
|
||||
pem = pb->printers.get_edited_preset().config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (pem) {
|
||||
k += "|pem";
|
||||
for (int v : pem->values) { k += ':'; k += std::to_string(v); }
|
||||
}
|
||||
}
|
||||
k += "|obj";
|
||||
for (const auto& oi : obj_to_instance_set)
|
||||
k += std::to_string(oi.first) + "." + std::to_string(oi.second) + ",";
|
||||
for (const auto& kv : get_imex_head_filament_map())
|
||||
k += "|m" + std::to_string(kv.first) + "=" + std::to_string(kv.second);
|
||||
return k;
|
||||
}
|
||||
|
||||
void PartPlate::ensure_imex_ghosts()
|
||||
{
|
||||
std::string key = build_imex_ghost_cache_key();
|
||||
if (key != m_imex_ghost_cache_key) {
|
||||
m_imex_ghost_cache_key = key;
|
||||
calc_imex_ghosts();
|
||||
}
|
||||
}
|
||||
|
||||
bool PartPlate::resolve_active_mode_tools(std::string& out_tools_str, int& out_primary_phys) const
|
||||
{
|
||||
if (!wxGetApp().preset_bundle) return false;
|
||||
const DynamicPrintConfig& printer_cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config;
|
||||
auto* is_imex_opt = printer_cfg.option<ConfigOptionBool>("is_imex");
|
||||
if (!is_imex_opt || !is_imex_opt->value) return false;
|
||||
|
||||
std::string active_mode = get_imex_mode();
|
||||
if (active_mode == "primary" || active_mode.empty()) {
|
||||
const DynamicPrintConfig& proc_cfg = wxGetApp().preset_bundle->prints.get_edited_preset().config;
|
||||
if (auto* mo = proc_cfg.option<ConfigOptionString>("imex_parallel_mode"))
|
||||
active_mode = mo->value;
|
||||
}
|
||||
if (active_mode.empty() || active_mode == "primary") return false;
|
||||
|
||||
auto* names = printer_cfg.option<ConfigOptionStrings>("imex_mode_names");
|
||||
auto* tools = printer_cfg.option<ConfigOptionStrings>("imex_mode_active_tools");
|
||||
if (!names || !tools) return false;
|
||||
auto it = std::find(names->values.begin(), names->values.end(), active_mode);
|
||||
if (it == names->values.end()) return false;
|
||||
const size_t mode_idx = it - names->values.begin();
|
||||
if (mode_idx >= tools->values.size()) return false;
|
||||
|
||||
const int primary_phys = imex_primary_tool_for_mode(tools->values[mode_idx]);
|
||||
if (primary_phys < 0) return false;
|
||||
|
||||
out_tools_str = tools->values[mode_idx];
|
||||
out_primary_phys = primary_phys;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PartPlate::calc_imex_ghosts()
|
||||
{
|
||||
m_imex_ghost_volumes.clear();
|
||||
if (!m_plater || !m_model) return;
|
||||
if (obj_to_instance_set.empty()) return;
|
||||
|
||||
std::string active_tools_str;
|
||||
int primary_phys = -1;
|
||||
if (!resolve_active_mode_tools(active_tools_str, primary_phys)) 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.
|
||||
ensure_imex_zones();
|
||||
|
||||
const auto heads = parse_imex_active_tools(active_tools_str);
|
||||
|
||||
// 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
|
||||
// zero on most IMEX presets — sourcing offsets from it stacks every ghost on top
|
||||
// of the primary, which is what motivated this switch.
|
||||
auto center_for = [&](int phys) -> Vec2d {
|
||||
auto it = m_imex_head_zone_centers.find(phys);
|
||||
return (it == m_imex_head_zone_centers.end()) ? Vec2d::Zero() : it->second;
|
||||
};
|
||||
const Vec2d primary_off = center_for(primary_phys);
|
||||
|
||||
constexpr float GHOST_ALPHA = 0.55f;
|
||||
|
||||
// Mesh is object-local and identical across all instances and heads of a given object.
|
||||
// Build the merged TriangleMesh once per obj_idx and reuse across the inner head loop.
|
||||
// (Per-ghost GLModel::init_from still runs once each, since GLVolume owns its GLModel by value;
|
||||
// sharing a GLModel across GLVolumes would require API changes outside this task's scope.)
|
||||
std::map<int, TriangleMesh> mesh_by_obj;
|
||||
auto get_combined_mesh = [&](int obj_idx, const ModelObject* mo) -> const TriangleMesh& {
|
||||
auto it = mesh_by_obj.find(obj_idx);
|
||||
if (it != mesh_by_obj.end()) return it->second;
|
||||
TriangleMesh combined;
|
||||
for (const ModelVolume* mv : mo->volumes) {
|
||||
if (!mv->is_model_part()) continue;
|
||||
TriangleMesh tm = mv->mesh();
|
||||
tm.transform(mv->get_matrix());
|
||||
combined.merge(tm);
|
||||
}
|
||||
return mesh_by_obj.emplace(obj_idx, std::move(combined)).first->second;
|
||||
};
|
||||
|
||||
for (const auto& oi : obj_to_instance_set) {
|
||||
const int obj_idx = oi.first;
|
||||
const int inst_idx = oi.second;
|
||||
if (obj_idx < 0 || obj_idx >= (int)m_model->objects.size()) continue;
|
||||
ModelObject* mo = m_model->objects[obj_idx];
|
||||
if (!mo || inst_idx < 0 || inst_idx >= (int)mo->instances.size()) continue;
|
||||
ModelInstance* mi = mo->instances[inst_idx];
|
||||
|
||||
const Transform3d inst_world = mi->get_matrix();
|
||||
const TriangleMesh& combined = get_combined_mesh(obj_idx, mo);
|
||||
|
||||
for (const auto& [phys, role] : heads) {
|
||||
if (phys == primary_phys) continue;
|
||||
if (phys >= IMEX_GHOST_MAX_HEADS) continue;
|
||||
|
||||
const Vec2d gantry = center_for(phys) - primary_off;
|
||||
// Mirror needs the primary instance's origin so the geometric flip happens
|
||||
// about that point instead of the old gantry midplane; Copy ignores it.
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, phys, role, gantry, inst_world.translation());
|
||||
|
||||
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->force_transparent = 1;
|
||||
ghost->force_native_color = 1;
|
||||
ghost->disabled = 1; // skip selection path
|
||||
// is_active defaults to true in GLVolume's ctor — leave it alone.
|
||||
ghost->zoom_to_volumes = 0;
|
||||
// Ghosts live in secondary zones that are by definition outside the primary
|
||||
// printable area; suppress the red "outside bed" overlay for them.
|
||||
ghost->shader_outside_printer_detection_enabled = 0;
|
||||
ghost->picking = 1;
|
||||
// object_id = sentinel-encoded physical head; volume_id = source obj_idx (for live-drag);
|
||||
// instance_id = source inst_idx.
|
||||
ghost->composite_id = GLVolume::CompositeID(
|
||||
imex_ghost_composite_id_for_head(phys), obj_idx, inst_idx);
|
||||
ghost->model.init_from(combined);
|
||||
m_imex_ghost_volumes.push_back(std::move(ghost));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PartPlate::update_imex_ghost_transforms(
|
||||
const std::function<std::optional<Transform3d>(int, int)>& primary_live_xf)
|
||||
{
|
||||
if (m_imex_ghost_volumes.empty() || !m_plater || !m_model) return;
|
||||
|
||||
std::string active_tools_str;
|
||||
int primary_phys = -1;
|
||||
if (!resolve_active_mode_tools(active_tools_str, primary_phys)) return;
|
||||
|
||||
// Reuse zone-center-derived offsets (same source calc_imex_ghosts uses), so
|
||||
// update and rebuild paths always agree on where each ghost belongs.
|
||||
ensure_imex_zones();
|
||||
auto center_for = [&](int phys) -> Vec2d {
|
||||
auto it = m_imex_head_zone_centers.find(phys);
|
||||
return (it == m_imex_head_zone_centers.end()) ? Vec2d::Zero() : it->second;
|
||||
};
|
||||
const Vec2d primary_off = center_for(primary_phys);
|
||||
|
||||
// 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))
|
||||
role_by_phys[phys] = role;
|
||||
auto role_for = [&](int phys) -> ImexRole {
|
||||
auto it = role_by_phys.find(phys);
|
||||
return (it == role_by_phys.end()) ? ImexRole::Copy : it->second;
|
||||
};
|
||||
|
||||
for (auto& ghost : m_imex_ghost_volumes) {
|
||||
const int head = imex_ghost_head_from_composite_id(ghost->composite_id.object_id);
|
||||
const int inst_idx = ghost->composite_id.instance_id;
|
||||
const int obj_idx = ghost->composite_id.volume_id; // stashed by calc_imex_ghosts
|
||||
if (obj_idx < 0 || obj_idx >= (int)m_model->objects.size()) continue;
|
||||
const ModelObject* mo = m_model->objects[obj_idx];
|
||||
if (!mo || inst_idx < 0 || inst_idx >= (int)mo->instances.size()) continue;
|
||||
|
||||
// Live-drag path: GLVolume carries the in-progress gizmo transform, while
|
||||
// ModelInstance::get_matrix() only reflects the last committed state. Use
|
||||
// the live lookup when the caller provides it so ghosts track drags frame
|
||||
// by frame instead of snapping on mouse-up.
|
||||
Transform3d primary_xf;
|
||||
if (primary_live_xf) {
|
||||
if (auto live = primary_live_xf(obj_idx, inst_idx))
|
||||
primary_xf = *live;
|
||||
else
|
||||
primary_xf = mo->instances[inst_idx]->get_matrix();
|
||||
} else {
|
||||
primary_xf = mo->instances[inst_idx]->get_matrix();
|
||||
}
|
||||
|
||||
const Vec2d gantry = center_for(head) - primary_off;
|
||||
// Mirror uses primary_xf.translation() as the flip plane anchor so the ghost's
|
||||
// geometry reflects about the primary's current position rather than a fixed
|
||||
// midplane; Copy ignores this argument.
|
||||
const Transform3d head_xf = imex_head_transform(
|
||||
primary_phys, head, role_for(head), gantry, primary_xf.translation());
|
||||
ghost->set_instance_transformation(head_xf * primary_xf);
|
||||
}
|
||||
}
|
||||
|
||||
bool PartPlate::has_imex_placement_violations()
|
||||
{
|
||||
ensure_imex_zones();
|
||||
@@ -943,6 +1243,7 @@ void PartPlate::render_imex_zones(bool force_default_color)
|
||||
return;
|
||||
|
||||
ensure_imex_zones();
|
||||
ensure_imex_ghosts();
|
||||
|
||||
// Read visualization theme from printer config.
|
||||
struct IMEXTheme {
|
||||
@@ -5461,6 +5762,12 @@ int PartPlateList::get_plate_count() const
|
||||
return ret;
|
||||
}
|
||||
|
||||
void PartPlateList::invalidate_all_imex_ghosts()
|
||||
{
|
||||
for (PartPlate* p : m_plate_list)
|
||||
if (p) p->invalidate_imex_ghosts();
|
||||
}
|
||||
|
||||
//update the plate cols due to plate count change
|
||||
void PartPlateList::update_plate_cols()
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#ifndef __part_plate_hpp_
|
||||
#define __part_plate_hpp_
|
||||
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <set>
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
@@ -143,7 +145,11 @@ private:
|
||||
std::vector<GLModel> m_imex_collision_overlay; // red-orange rendered fill for danger strips
|
||||
std::vector<GLModel> m_imex_margin_overlay; // amber advisory bands just inside collision strips
|
||||
std::optional<BoundingBoxf> m_imex_primary_zone_box; // primary zone extents in mm; empty when IDEX/IQEX is off/primary-only
|
||||
std::map<int, Vec2d> m_imex_head_zone_centers; // physical-head → zone-center (mm); populated by calc_imex_zones
|
||||
int m_imex_primary_head{ -1 }; // physical-head index of the primary tool in the active mode; -1 when no mode
|
||||
std::string m_imex_zones_mode_cache{ "\x01" }; // cached mode+topology key; sentinel = unbuilt
|
||||
std::vector<std::unique_ptr<GLVolume>> m_imex_ghost_volumes;
|
||||
std::string m_imex_ghost_cache_key{ "\x01" }; // sentinel = unbuilt
|
||||
GLModel m_height_limit_common;
|
||||
GLModel m_height_limit_bottom;
|
||||
GLModel m_height_limit_top;
|
||||
@@ -191,6 +197,14 @@ private:
|
||||
void calc_imex_zones();
|
||||
void ensure_imex_zones();
|
||||
std::string build_imex_cache_key() const;
|
||||
void calc_imex_ghosts(); // full rebuild
|
||||
void ensure_imex_ghosts(); // cached rebuild on key change
|
||||
std::string build_imex_ghost_cache_key() const;
|
||||
// Resolves the currently-active IMEX mode's tools string + primary physical head.
|
||||
// Plate override wins; else falls back to process preset `imex_parallel_mode`.
|
||||
// Returns false (and leaves outputs untouched) if IMEX is off, mode is empty/"primary",
|
||||
// mode config is missing, the mode isn't listed, or no primary is found.
|
||||
bool resolve_active_mode_tools(std::string& out_tools_str, int& out_primary_phys) const;
|
||||
void render_imex_zones(bool force_default_color);
|
||||
void refresh_imex_icon();
|
||||
void calc_vertex_for_number(int index, bool one_number, GLModel &buffer);
|
||||
@@ -229,6 +243,23 @@ public:
|
||||
static constexpr unsigned int PLATE_IMEX_MODE_ID = 9;
|
||||
static constexpr unsigned int GRABBER_COUNT = 10;
|
||||
|
||||
// Sentinel encoded into GLVolume::composite_id.object_id for IMEX ghost volumes.
|
||||
// Real object_ids are small non-negative ints, so a large-negative base is unambiguous.
|
||||
// physical_head = (sentinel - IMEX_GHOST_COMPOSITE_ID_BASE); valid for 0 <= head < IMEX_GHOST_MAX_HEADS.
|
||||
static constexpr int IMEX_GHOST_COMPOSITE_ID_BASE = -100000;
|
||||
static constexpr int IMEX_GHOST_MAX_HEADS = 256;
|
||||
|
||||
static bool is_imex_ghost_composite_id(int object_id) {
|
||||
return object_id <= IMEX_GHOST_COMPOSITE_ID_BASE
|
||||
&& object_id > IMEX_GHOST_COMPOSITE_ID_BASE - IMEX_GHOST_MAX_HEADS;
|
||||
}
|
||||
static int imex_ghost_head_from_composite_id(int object_id) {
|
||||
return IMEX_GHOST_COMPOSITE_ID_BASE - object_id;
|
||||
}
|
||||
static int imex_ghost_composite_id_for_head(int physical_head) {
|
||||
return IMEX_GHOST_COMPOSITE_ID_BASE - physical_head;
|
||||
}
|
||||
|
||||
static ColorRGBA SELECT_COLOR;
|
||||
static ColorRGBA UNSELECT_COLOR;
|
||||
static ColorRGBA UNSELECT_DARK_COLOR;
|
||||
@@ -291,6 +322,34 @@ public:
|
||||
void set_imex_mode(const std::string& mode);
|
||||
void reset_imex_mode();
|
||||
|
||||
// Per-plate head→filament override (PR3b).
|
||||
// Map keys are physical T-indices; values are 1-based filament slots (matches UI numbering).
|
||||
// Empty map = "no overrides stored"; G-code falls back to first_filament_for_physical_head.
|
||||
std::map<int,int> get_imex_head_filament_map() const;
|
||||
void set_imex_head_filament_map(const std::map<int,int>& m);
|
||||
void reset_imex_head_filament_map();
|
||||
|
||||
// Returns the RGBA color a ghost rendered for `physical_head` should use,
|
||||
// respecting any imex_head_filament_map override. Falls back to GLVolume::UNPRINTABLE_COLOR
|
||||
// when no filament routes to the head.
|
||||
ColorRGBA get_imex_head_filament_color(int physical_head) const;
|
||||
|
||||
// Visual ghosts of every object on this plate, one per active secondary head.
|
||||
// Non-selectable, non-draggable. See IMEX ghost renderer spec.
|
||||
const std::vector<std::unique_ptr<GLVolume>>& get_imex_ghost_volumes() const {
|
||||
return m_imex_ghost_volumes;
|
||||
}
|
||||
|
||||
// Live-drag path: update ghost transforms only, no factory rebuild.
|
||||
// If `primary_live_xf(obj_idx, inst_idx)` is provided it's consulted for the primary's
|
||||
// transform (e.g. the live GLVolume matrix during gizmo drags); when it returns nullopt
|
||||
// or no lookup is passed, the committed ModelInstance matrix is used.
|
||||
void update_imex_ghost_transforms(
|
||||
const std::function<std::optional<Transform3d>(int, int)>& primary_live_xf = {});
|
||||
|
||||
// Invalidate the ghost cache so the next render_imex_zones() call rebuilds fully.
|
||||
void invalidate_imex_ghosts() { m_imex_ghost_cache_key = "\x01"; }
|
||||
|
||||
std::vector<Vec2d> get_plate_wrapping_detection_area() const;
|
||||
|
||||
//static const int plate_x_offset = 20; //mm
|
||||
@@ -806,6 +865,11 @@ public:
|
||||
//get the plate counts, not including the invalid plate
|
||||
int get_plate_count() const;
|
||||
|
||||
// Invalidate every plate's IMEX ghost cache. Use after any batch mutation
|
||||
// (selection commits, undo/redo) that can move objects between plates or
|
||||
// change per-instance transforms without flowing through set_imex_*.
|
||||
void invalidate_all_imex_ghosts();
|
||||
|
||||
//update the plate cols due to plate count change
|
||||
void update_plate_cols();
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
#include "libslic3r/SLAPrint.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
#include "libslic3r/ClipperUtils.hpp"
|
||||
#include "libslic3r/ObjColorUtils.hpp"
|
||||
// For stl export
|
||||
@@ -91,6 +92,7 @@
|
||||
#include "GUI_Preview.hpp"
|
||||
#include "3DBed.hpp"
|
||||
#include "PartPlate.hpp"
|
||||
#include "IMEXFilamentPickerPopover.hpp"
|
||||
#include "Camera.hpp"
|
||||
#include "Mouse3DController.hpp"
|
||||
#include "Tab.hpp"
|
||||
@@ -9956,14 +9958,18 @@ static std::vector<wxString> collect_imex_warnings(PartPlate* plate)
|
||||
const size_t max_tool = std::min(bundle->filament_presets.size(), MAXIMUM_EXTRUDER_NUMBER);
|
||||
|
||||
std::vector<int> active_tools;
|
||||
int primary_tool = -1;
|
||||
if (mode_names_opt && tools_opt) {
|
||||
for (size_t i = 0; i < mode_names_opt->values.size(); ++i) {
|
||||
if (i >= tools_opt->values.size() || mode_names_opt->values[i] != mode) continue;
|
||||
std::istringstream ss(tools_opt->values[i]);
|
||||
const std::string& entry = tools_opt->values[i];
|
||||
primary_tool = imex_primary_tool_for_mode(entry);
|
||||
std::istringstream ss(entry);
|
||||
std::string token;
|
||||
while (std::getline(ss, token, ',')) {
|
||||
token.erase(std::remove_if(token.begin(), token.end(), ::isspace), token.end());
|
||||
if (token.empty()) continue;
|
||||
// std::stoi parses the leading integer and ignores any ":P/:C/:M" suffix.
|
||||
try {
|
||||
int idx = std::stoi(token);
|
||||
if (idx >= 0 && (max_tool == 0 || (size_t)idx < max_tool))
|
||||
@@ -9974,9 +9980,7 @@ static std::vector<wxString> collect_imex_warnings(PartPlate* plate)
|
||||
}
|
||||
}
|
||||
|
||||
if (active_tools.size() < 2) return warnings;
|
||||
|
||||
const int primary_tool = active_tools[0];
|
||||
if (active_tools.size() < 2 || primary_tool < 0) return warnings;
|
||||
const DynamicPrintConfig& full_cfg = bundle->full_config();
|
||||
// Bed temps are per-plate-type; resolve the active plate's bed type to get the right key.
|
||||
const BedType bed_type = bundle->project_config.opt_enum<BedType>("curr_bed_type");
|
||||
@@ -9994,8 +9998,9 @@ static std::vector<wxString> collect_imex_warnings(PartPlate* plate)
|
||||
}
|
||||
if (primary_display_type.empty()) primary_display_type = "unknown";
|
||||
|
||||
for (size_t i = 1; i < active_tools.size(); ++i) {
|
||||
for (size_t i = 0; i < active_tools.size(); ++i) {
|
||||
const int tool_idx = active_tools[i];
|
||||
if (tool_idx == primary_tool) continue;
|
||||
|
||||
std::string secondary_display_type;
|
||||
if (tool_idx < (int)filament_presets.size()) {
|
||||
@@ -18004,12 +18009,13 @@ int Plater::select_plate_by_hover_id(int hover_id, bool right_click, bool isModi
|
||||
p->view3D->get_canvas3d()->get_wxglcanvas()->PopupMenu(&menu);
|
||||
ret = 1; // signal to caller: popup was shown, suppress plate context menu
|
||||
} else {
|
||||
// Left-click: cycle to next mode.
|
||||
std::string current = curr_plate->get_imex_mode();
|
||||
auto it = std::find(modes.begin(), modes.end(), current);
|
||||
// Left-click: always cycles mode. The IMEX ghost renderer exposes
|
||||
// the per-head filament picker on the plate itself; no popover here.
|
||||
const std::string current_mode = curr_plate->get_imex_mode();
|
||||
auto it = std::find(modes.begin(), modes.end(), current_mode);
|
||||
size_t next_idx = (it == modes.end()) ? 0 : ((it - modes.begin() + 1) % modes.size());
|
||||
std::string next_mode = modes[next_idx];
|
||||
if (next_mode != current) {
|
||||
if (next_mode != current_mode) {
|
||||
take_snapshot("set imex mode");
|
||||
curr_plate->set_imex_mode(next_mode);
|
||||
update_project_dirty_from_presets();
|
||||
@@ -18534,6 +18540,60 @@ void Plater::update_title_dirty_status()
|
||||
p->update_title_dirty_status();
|
||||
}
|
||||
|
||||
Plater::ImexGhostTooltip Plater::format_imex_ghost_tooltip(int physical_head) const
|
||||
{
|
||||
ImexGhostTooltip t{physical_head, -1, GLVolume::UNPRINTABLE_COLOR, {}};
|
||||
|
||||
const PartPlate* plate = p->partplate_list.get_curr_plate();
|
||||
if (!plate) {
|
||||
t.label = "T" + std::to_string(physical_head) + " -> (no plate)";
|
||||
return t;
|
||||
}
|
||||
|
||||
const ConfigOptionInts* pem = wxGetApp().preset_bundle->project_config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem || pem->values.size() < 2)
|
||||
pem = wxGetApp().preset_bundle->printers.get_edited_preset().config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
|
||||
const auto map = plate->get_imex_head_filament_map();
|
||||
const int logical = pem ? resolve_filament_for_head(map, *pem, physical_head) : -1;
|
||||
if (logical < 0) {
|
||||
t.label = "T" + std::to_string(physical_head) + " -> (no filament routed)";
|
||||
return t;
|
||||
}
|
||||
t.filament_slot_1based = logical + 1;
|
||||
t.swatch = plate->get_imex_head_filament_color(physical_head);
|
||||
t.swatch.a(1.0f); // tooltip swatch opaque
|
||||
t.label = "T" + std::to_string(physical_head) +
|
||||
" -> filament " + std::to_string(t.filament_slot_1based);
|
||||
return t;
|
||||
}
|
||||
|
||||
void Plater::on_imex_ghost_click(int physical_head)
|
||||
{
|
||||
const ConfigOptionInts* pem = wxGetApp().preset_bundle->project_config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem || pem->values.size() < 2)
|
||||
pem = wxGetApp().preset_bundle->printers.get_edited_preset().config.option<ConfigOptionInts>("physical_extruder_map");
|
||||
if (!pem) return;
|
||||
|
||||
int lane_count = 0;
|
||||
for (int pv : pem->values) if (pv == physical_head) ++lane_count;
|
||||
if (lane_count < 2) return; // single-lane head -> click is a no-op, tooltip conveyed status
|
||||
|
||||
PartPlate* plate = p->partplate_list.get_curr_plate();
|
||||
if (!plate) return;
|
||||
|
||||
auto* picker = new IMEXFilamentPickerPopover(
|
||||
p->view3D->get_canvas3d()->get_wxglcanvas(),
|
||||
plate, *pem, physical_head,
|
||||
[this]() {
|
||||
take_snapshot("edit imex head filament");
|
||||
update_project_dirty_from_presets();
|
||||
set_plater_dirty(true);
|
||||
update();
|
||||
});
|
||||
picker->popup_at_cursor();
|
||||
}
|
||||
|
||||
|
||||
wxMenu* Plater::plate_menu() { return p->menus.plate_menu(); }
|
||||
wxMenu* Plater::object_menu() { return p->menus.object_menu(); }
|
||||
|
||||
@@ -628,6 +628,18 @@ public:
|
||||
void unbind_canvas_event_handlers();
|
||||
void reset_canvas_volumes();
|
||||
|
||||
// Dispatch a left-click on an IMEX ghost volume in the 3D canvas to the plater-level
|
||||
// popover. Implemented in Task 7; stub body for Task 6.
|
||||
void on_imex_ghost_click(int physical_head);
|
||||
|
||||
struct ImexGhostTooltip {
|
||||
int physical_head;
|
||||
int filament_slot_1based; // -1 if no routing
|
||||
ColorRGBA swatch;
|
||||
std::string label; // "T2 -> filament 6" or "T2 -> (no filament)"
|
||||
};
|
||||
ImexGhostTooltip format_imex_ghost_tooltip(int physical_head) const;
|
||||
|
||||
PrinterTechnology printer_technology() const;
|
||||
const DynamicPrintConfig * config() const;
|
||||
bool set_printer_technology(PrinterTechnology printer_technology);
|
||||
|
||||
@@ -4483,6 +4483,9 @@ public:
|
||||
|
||||
// Parse "idx:P,idx:C,idx:M" → map<tool_idx, state>
|
||||
// Backwards compat: plain "idx" (no role) → state 1 (Primary)
|
||||
// Indices are PHYSICAL tool indices (T0..TN-1). all_tool_states preserves
|
||||
// off-grid entries so the tile widget can round-trip indices that fall
|
||||
// outside the currently visible rows × cols without losing data on save.
|
||||
static std::map<int,int> parse_tool_states(const std::string& s) {
|
||||
std::map<int,int> result;
|
||||
if (s.empty()) return result;
|
||||
@@ -5380,7 +5383,9 @@ if (is_marlin_flavor)
|
||||
"dialog itself. Re-enable here if suppressed accidentally.") };
|
||||
line.full_width = 1;
|
||||
line.widget = [](wxWindow* parent) -> wxSizer* {
|
||||
auto* cb = new wxCheckBox(parent, wxID_ANY, wxEmptyString);
|
||||
// Label belongs on the checkbox itself: full_width=1 (required to
|
||||
// dodge the Windows crash in activate_line) skips Line-title rendering.
|
||||
auto* cb = new wxCheckBox(parent, wxID_ANY, _L("Pre-slice warnings"));
|
||||
bool enabled = wxGetApp().app_config->get("imex_pre_slice_warnings") != "false";
|
||||
cb->SetValue(enabled);
|
||||
cb->Bind(wxEVT_CHECKBOX, [cb](wxCommandEvent&) {
|
||||
|
||||
@@ -11,6 +11,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_config.cpp
|
||||
test_elephant_foot_compensation.cpp
|
||||
test_geometry.cpp
|
||||
test_imex_helpers.cpp
|
||||
test_placeholder_parser.cpp
|
||||
test_polygon.cpp
|
||||
test_mutable_polygon.cpp
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
static ConfigOptionInts make_pem(std::vector<int> v) {
|
||||
ConfigOptionInts o;
|
||||
o.values = std::move(v);
|
||||
return o;
|
||||
}
|
||||
|
||||
TEST_CASE("first_filament_for_physical_head — identity pem", "[IMEX]") {
|
||||
auto pem = make_pem({0, 1, 2, 3});
|
||||
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 1) == 1);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 2) == 2);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 3) == 3);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 4) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("first_filament_for_physical_head — AFC routing", "[IMEX]") {
|
||||
// User's IQEX: 4 AFC lanes on T0, direct extruders on T1, T2, T3
|
||||
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
||||
REQUIRE(first_filament_for_physical_head(pem, 0) == 0); // first of T0's lanes
|
||||
REQUIRE(first_filament_for_physical_head(pem, 1) == 4);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 2) == 5);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 3) == 6);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 5) == -1); // no head 5
|
||||
}
|
||||
|
||||
TEST_CASE("first_filament_for_physical_head — empty pem", "[IMEX]") {
|
||||
ConfigOptionInts pem;
|
||||
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
||||
REQUIRE(first_filament_for_physical_head(pem, 1) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("has_mmu — pure IDEX", "[IMEX]") {
|
||||
REQUIRE_FALSE(has_mmu(make_pem({0, 1})));
|
||||
REQUIRE_FALSE(has_mmu(make_pem({0, 1, 2, 3})));
|
||||
}
|
||||
|
||||
TEST_CASE("has_mmu — MMU on one head", "[IMEX]") {
|
||||
REQUIRE(has_mmu(make_pem({0, 0, 0, 0, 1, 2, 3}))); // user's IQEX
|
||||
REQUIRE(has_mmu(make_pem({0, 0}))); // tiny MMU
|
||||
}
|
||||
|
||||
TEST_CASE("has_mmu — MMU on second head", "[IMEX]") {
|
||||
// Hypothetical future: direct extruder on T0, MMU on T1
|
||||
REQUIRE(has_mmu(make_pem({0, 1, 1, 1})));
|
||||
}
|
||||
|
||||
TEST_CASE("has_mmu — empty / single-entry pem", "[IMEX]") {
|
||||
REQUIRE_FALSE(has_mmu(make_pem({})));
|
||||
REQUIRE_FALSE(has_mmu(make_pem({0})));
|
||||
}
|
||||
|
||||
TEST_CASE("parse_imex_head_filament_map — round-trip", "[IMEX]") {
|
||||
auto m = parse_imex_head_filament_map("0:3,4:5");
|
||||
REQUIRE(m.size() == 2);
|
||||
REQUIRE(m[0] == 3);
|
||||
REQUIRE(m[4] == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_imex_head_filament_map — whitespace + empty tokens", "[IMEX]") {
|
||||
auto m = parse_imex_head_filament_map(" 0 : 3 , , 4:5 ");
|
||||
REQUIRE(m.size() == 2);
|
||||
REQUIRE(m[0] == 3);
|
||||
REQUIRE(m[4] == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_imex_head_filament_map — empty string", "[IMEX]") {
|
||||
REQUIRE(parse_imex_head_filament_map("").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_filament_for_head — override wins", "[IMEX]") {
|
||||
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
||||
std::map<int,int> plate_map{{0, 3}}; // 1-based slot 3 = 0-based logical 2
|
||||
REQUIRE(resolve_filament_for_head(plate_map, pem, 0) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_filament_for_head — fallback when unset", "[IMEX]") {
|
||||
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
||||
std::map<int,int> plate_map{}; // empty
|
||||
REQUIRE(resolve_filament_for_head(plate_map, pem, 0) == 0); // first pem-routed
|
||||
REQUIRE(resolve_filament_for_head(plate_map, pem, 1) == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_filament_for_head — no routing for head", "[IMEX]") {
|
||||
auto pem = make_pem({0, 1}); // no head 2
|
||||
std::map<int,int> plate_map{};
|
||||
REQUIRE(resolve_filament_for_head(plate_map, pem, 2) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("imex_primary_tool_for_mode — role marker authoritative", "[IMEX]") {
|
||||
// Tab.cpp enforces one Primary per mode; position is not semantically meaningful.
|
||||
REQUIRE(imex_primary_tool_for_mode("0:P,1:C,2:C") == 0);
|
||||
REQUIRE(imex_primary_tool_for_mode("1:C,0:P,2:M") == 0);
|
||||
REQUIRE(imex_primary_tool_for_mode("1:C,2:M,3:P") == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("imex_primary_tool_for_mode — backwards-compat plain index", "[IMEX]") {
|
||||
REQUIRE(imex_primary_tool_for_mode("0") == 0);
|
||||
REQUIRE(imex_primary_tool_for_mode("2") == 2);
|
||||
// Bare index wins when no :P marker is present; first bare index takes primary.
|
||||
REQUIRE(imex_primary_tool_for_mode("1,2,3") == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("imex_primary_tool_for_mode — explicit :P beats bare index", "[IMEX]") {
|
||||
// Mixed serialization: role marker must dominate over bare-index fallback.
|
||||
REQUIRE(imex_primary_tool_for_mode("1,2:P,3") == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("imex_primary_tool_for_mode — empty + malformed", "[IMEX]") {
|
||||
REQUIRE(imex_primary_tool_for_mode("") == -1);
|
||||
REQUIRE(imex_primary_tool_for_mode(",,") == -1);
|
||||
REQUIRE(imex_primary_tool_for_mode("abc:P") == -1); // idx not parseable
|
||||
REQUIRE(imex_primary_tool_for_mode("-1:P") == -1); // negative idx rejected
|
||||
}
|
||||
|
||||
TEST_CASE("imex_primary_tool_for_mode — whitespace tolerant", "[IMEX]") {
|
||||
REQUIRE(imex_primary_tool_for_mode(" 0 : P , 1 : C ") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("has_non_primary_mmu — MMU on primary only", "[IMEX]") {
|
||||
// User's Neo XP 0.6: 4 AFC lanes on T0, singles on T4/T5/T6.
|
||||
// Primary = 0 → no other head has MMU.
|
||||
auto pem = make_pem({0, 0, 0, 0, 4, 5, 6});
|
||||
REQUIRE_FALSE(has_non_primary_mmu(pem, 0));
|
||||
}
|
||||
|
||||
TEST_CASE("has_non_primary_mmu — MMU on secondary", "[IMEX]") {
|
||||
// Direct extruder on T0, MMU on T1 → non-primary MMU present.
|
||||
auto pem = make_pem({0, 1, 1, 1});
|
||||
REQUIRE(has_non_primary_mmu(pem, 0));
|
||||
}
|
||||
|
||||
TEST_CASE("has_non_primary_mmu — pure IDEX", "[IMEX]") {
|
||||
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0, 1}), 0));
|
||||
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0, 1, 2, 3}), 0));
|
||||
}
|
||||
|
||||
TEST_CASE("has_non_primary_mmu — secondary single-lane", "[IMEX]") {
|
||||
// Primary is T4 (single filament); T0 has MMU.
|
||||
auto pem = make_pem({0, 0, 0, 0, 4});
|
||||
REQUIRE(has_non_primary_mmu(pem, 4));
|
||||
}
|
||||
|
||||
TEST_CASE("has_non_primary_mmu — empty / single-entry pem", "[IMEX]") {
|
||||
REQUIRE_FALSE(has_non_primary_mmu(make_pem({}), 0));
|
||||
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0}), 0));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — copy mode is pure translation", "[IMEX]") {
|
||||
const Vec2d offset{120.0, 0.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Copy, offset);
|
||||
const Vec3d in{10.0, 20.0, 30.0};
|
||||
const Vec3d out = xf * in;
|
||||
REQUIRE_THAT(out.x(), WithinAbs(130.0, 1e-9));
|
||||
REQUIRE_THAT(out.y(), WithinAbs(20.0, 1e-9));
|
||||
REQUIRE_THAT(out.z(), WithinAbs(30.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror at origin places ghost at gantry offset", "[IMEX]") {
|
||||
// Primary instance at origin: ghost origin lands at the gantry offset (Copy-style),
|
||||
// and applying mirror flips geometry about origin (= primary's translation).
|
||||
const Vec2d offset{120.0, 0.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
||||
const Vec3d mapped = xf * Vec3d::Zero();
|
||||
REQUIRE_THAT(mapped.x(), WithinAbs(120.0, 1e-9));
|
||||
REQUIRE_THAT(mapped.y(), WithinAbs(0.0, 1e-9));
|
||||
REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror flips geometry in-place about primary origin", "[IMEX]") {
|
||||
// Primary sits at (50, 10, 0). Gantry +120 in X → ghost origin at (170, 10, 0),
|
||||
// NOT reflected about a midplane. A model point at +5 X from primary origin ends
|
||||
// up at -5 X from the ghost origin (geometric flip preserved).
|
||||
const Vec2d offset{120.0, 0.0};
|
||||
const Vec3d primary_origin{50.0, 10.0, 0.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_origin);
|
||||
|
||||
const Vec3d ghost_origin = xf * primary_origin;
|
||||
REQUIRE_THAT(ghost_origin.x(), WithinAbs(170.0, 1e-9)); // 50 + 120 (Copy-style)
|
||||
REQUIRE_THAT(ghost_origin.y(), WithinAbs(10.0, 1e-9));
|
||||
|
||||
const Vec3d plus5 = primary_origin + Vec3d(5.0, 0.0, 0.0);
|
||||
const Vec3d mapped = xf * plus5;
|
||||
REQUIRE_THAT(mapped.x(), WithinAbs(165.0, 1e-9)); // ghost_origin - 5 (flipped)
|
||||
REQUIRE_THAT(mapped.y(), WithinAbs(10.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror tracks primary motion (same delta as Copy)", "[IMEX]") {
|
||||
// When the primary moves by some delta, the ghost origin must move by the SAME delta
|
||||
// (not the reflected delta). This is the core user requirement: mirrored geometry,
|
||||
// un-mirrored motion.
|
||||
const Vec2d offset{120.0, 0.0};
|
||||
const Vec3d p0{0.0, 0.0, 0.0};
|
||||
const Vec3d p1{30.0, -5.0, 0.0};
|
||||
|
||||
Transform3d xf0 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p0);
|
||||
Transform3d xf1 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p1);
|
||||
|
||||
const Vec3d ghost0 = xf0 * p0;
|
||||
const Vec3d ghost1 = xf1 * p1;
|
||||
const Vec3d ghost_delta = ghost1 - ghost0;
|
||||
const Vec3d primary_delta = p1 - p0;
|
||||
|
||||
REQUIRE_THAT(ghost_delta.x(), WithinAbs(primary_delta.x(), 1e-9));
|
||||
REQUIRE_THAT(ghost_delta.y(), WithinAbs(primary_delta.y(), 1e-9));
|
||||
REQUIRE_THAT(ghost_delta.z(), WithinAbs(primary_delta.z(), 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror reflects model point across primary origin", "[IMEX]") {
|
||||
// Primary at origin, offset +X. Model point at +5 X lands 5 left of ghost origin.
|
||||
// Matches the pre-refactor semantics for the special case primary_origin = 0.
|
||||
const Vec2d offset{120.0, 0.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
||||
const Vec3d in{5.0, 7.0, 0.0};
|
||||
const Vec3d out = xf * in;
|
||||
REQUIRE_THAT(out.x(), WithinAbs(115.0, 1e-9));
|
||||
REQUIRE_THAT(out.y(), WithinAbs(7.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror reflection is X-axis regardless of offset direction", "[IMEX]") {
|
||||
// Mirror's reflection plane normal is the primary-row gantry axis (X), not
|
||||
// gantry_offset.normalized(). A pure-Y offset (off-row target) must still flip
|
||||
// X, not Y — otherwise off-row Mirror ghosts end up rotated vs their on-row peers.
|
||||
const Vec2d offset{0.0, 80.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
||||
const Vec3d in{3.0, 10.0, 0.0};
|
||||
const Vec3d out = xf * in;
|
||||
REQUIRE_THAT(out.x(), WithinAbs(-3.0, 1e-9)); // X flipped about origin
|
||||
REQUIRE_THAT(out.y(), WithinAbs(90.0, 1e-9)); // Y translated by gantry, unflipped
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — primary is identity", "[IMEX]") {
|
||||
const Vec2d offset{120.0, 30.0};
|
||||
Transform3d xf = imex_head_transform(0, 0, ImexRole::Primary, offset);
|
||||
REQUIRE(xf.isApprox(Transform3d::Identity()));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror with zero offset is identity", "[IMEX]") {
|
||||
const Vec2d offset{0.0, 0.0};
|
||||
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
||||
REQUIRE(xf.isApprox(Transform3d::Identity()));
|
||||
}
|
||||
|
||||
TEST_CASE("imex_head_transform — mirror on 2x2 off-row target (diagonal offset) flips X only", "[IMEX]") {
|
||||
// 2x2 IMEX layout: primary T0 at rear-left, target T3 at front-right → diagonal
|
||||
// gantry_offset. Reflection plane must still be X-axis (same as on-row T1 mirror),
|
||||
// not the diagonal direction — otherwise T3's ghost reads as rotated ~45° in plan
|
||||
// view (the bug this test guards against). Primary origin (0,0,0) maps to target
|
||||
// origin (100,100,0) regardless.
|
||||
const Vec2d offset{100.0, 100.0};
|
||||
Transform3d xf = imex_head_transform(0, 3, ImexRole::Mirror, offset);
|
||||
|
||||
const Vec3d mapped = xf * Vec3d::Zero();
|
||||
REQUIRE_THAT(mapped.x(), WithinAbs(100.0, 1e-9));
|
||||
REQUIRE_THAT(mapped.y(), WithinAbs(100.0, 1e-9));
|
||||
REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9));
|
||||
|
||||
// A model point offset +5 X from primary ends up 5 LEFT of the ghost origin
|
||||
// (X flipped), while Y translates 1:1 (Y unflipped).
|
||||
const Vec3d in{5.0, 7.0, 0.0};
|
||||
const Vec3d out = xf * in;
|
||||
REQUIRE_THAT(out.x(), WithinAbs(95.0, 1e-9)); // 100 - 5
|
||||
REQUIRE_THAT(out.y(), WithinAbs(107.0, 1e-9)); // 100 + 7
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_filament_for_head — no routing returns -1 (ghost color fallback)", "[IMEX]") {
|
||||
// User's IQEX pem: T0 has 4 AFC lanes, T1/T2/T3 direct. T5 is unrouted.
|
||||
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
||||
std::map<int,int> no_override;
|
||||
REQUIRE(resolve_filament_for_head(no_override, pem, 5) == -1);
|
||||
|
||||
// Plate override for an unrouted head still resolves (user's explicit choice wins).
|
||||
std::map<int,int> override_on_5 = {{5, 7}};
|
||||
REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6);
|
||||
}
|
||||
Reference in New Issue
Block a user