From 77c32a2e156fde3b3dc90657e9afc2707cbd9b70 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Tue, 21 Apr 2026 00:27:40 -0400 Subject: [PATCH] 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. --- src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/Format/bbs_3mf.cpp | 9 + src/libslic3r/GCode.cpp | 51 ++- src/libslic3r/GCode.hpp | 3 + src/libslic3r/IMEXHelpers.cpp | 186 +++++++++++ src/libslic3r/IMEXHelpers.hpp | 89 +++++ src/libslic3r/Preset.cpp | 1 + src/libslic3r/PresetBundle.cpp | 8 + src/libslic3r/PrintConfig.cpp | 7 + src/libslic3r/PrintConfig.hpp | 4 + src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/GLCanvas3D.cpp | 181 +++++++++++ src/slic3r/GUI/GLCanvas3D.hpp | 11 + src/slic3r/GUI/IMEXFilamentPickerPopover.cpp | 124 +++++++ src/slic3r/GUI/IMEXFilamentPickerPopover.hpp | 42 +++ src/slic3r/GUI/PartPlate.cpp | 321 ++++++++++++++++++- src/slic3r/GUI/PartPlate.hpp | 64 ++++ src/slic3r/GUI/Plater.cpp | 78 ++++- src/slic3r/GUI/Plater.hpp | 12 + src/slic3r/GUI/Tab.cpp | 7 +- tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_imex_helpers.cpp | 283 ++++++++++++++++ 22 files changed, 1458 insertions(+), 28 deletions(-) create mode 100644 src/libslic3r/IMEXHelpers.cpp create mode 100644 src/libslic3r/IMEXHelpers.hpp create mode 100644 src/slic3r/GUI/IMEXFilamentPickerPopover.cpp create mode 100644 src/slic3r/GUI/IMEXFilamentPickerPopover.hpp create mode 100644 tests/libslic3r/test_imex_helpers.cpp diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 5f9591452f..7c6ea1cf78 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -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 diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index fbda98bb14..dbc14848d0 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.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("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"); diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index e617e42743..2f10c40757 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -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 get_imex_active_tools(const Print& print) { std::vector 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); diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 79c1853683..265fc12305 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -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 m_imex_head_filament_map; std::unique_ptr m_wipe_tower; diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp new file mode 100644 index 0000000000..87cf8dcc7b --- /dev/null +++ b/src/libslic3r/IMEXHelpers.cpp @@ -0,0 +1,186 @@ +#include "libslic3r/IMEXHelpers.hpp" + +#include +#include +#include +#include +#include + +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(i); + } + return -1; +} + +bool has_mmu(const ConfigOptionInts& pem) +{ + if (pem.values.size() < 2) + return false; + std::unordered_set 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 seen; + for (int pv : pem.values) { + if (pv == primary_physical) + continue; + if (!seen.insert(pv).second) + return true; + } + return false; +} + +std::vector> parse_imex_active_tools(const std::string& active_tools_for_mode) +{ + std::vector> 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 parse_imex_head_filament_map(const std::string& s) +{ + std::map 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& 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 diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp new file mode 100644 index 0000000000..739d67f879 --- /dev/null +++ b/src/libslic3r/IMEXHelpers.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include + +#include +#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 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& 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> 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 diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index fc28d8e5ca..fd63b1b41f 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -963,6 +963,7 @@ static std::vector 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 s_Preset_filament_options {/*"filament_colour", */ "default_filament_colour", "required_nozzle_HRC", "filament_diameter", "pellet_flow_coefficient", "volumetric_speed_coefficients", "filament_type", diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 7c6522310c..50d798b7a9 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -49,6 +49,14 @@ static std::vector 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 diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 1d2cfb7c6a..c75b70b9d1 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -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."); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index f0b414e44f..b8ff0d0fd2 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -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. diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 1cd2c149bf..ca551fab93 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 06f825de73..4b681bb5f5 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.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(m_mouse.position.x()), + static_cast(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::infinity(); + double tmax = std::numeric_limits::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 { + 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(model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose().cast())); + 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(); + 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(); diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 5bde8e37a7..22d47751db 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -591,6 +591,9 @@ private: //BBS:add plate related logic mutable std::vector m_hover_volume_idxs; std::vector 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 diff --git a/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp b/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp new file mode 100644 index 0000000000..6a3fbf8988 --- /dev/null +++ b/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp @@ -0,0 +1,124 @@ +#include "slic3r/GUI/IMEXFilamentPickerPopover.hpp" + +#include +#include +#include + +#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 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 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 diff --git a/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp b/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp new file mode 100644 index 0000000000..02b24b3651 --- /dev/null +++ b/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp @@ -0,0 +1,42 @@ +#ifndef slic3r_IMEXFilamentPickerPopover_hpp_ +#define slic3r_IMEXFilamentPickerPopover_hpp_ + +#include +#include +#include + +#include + +#include "libslic3r/PrintConfig.hpp" + +namespace Slic3r { +namespace GUI { + +class PartPlate; + +class IMEXFilamentPickerPopover : public wxPopupTransientWindow +{ +public: + using CommitCallback = std::function; + + 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 diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 7940821e21..09bc91249c 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -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 PartPlate::get_imex_head_filament_map() const +{ + if (!m_config.has("imex_head_filament_map")) + return {}; + auto* opt = m_config.option("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& 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("physical_extruder_map"); + if (!pem || pem->values.size() < 2) + pem = pb->printers.get_edited_preset().config.option("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("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 (1=Primary, 2=Copy, 3=Mirror) + // Parse "phys_idx:P/C/M" format → map (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 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("physical_extruder_map"); + if (!pem || pem->values.size() < 2) + pem = pb->printers.get_edited_preset().config.option("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("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("imex_parallel_mode")) + active_mode = mo->value; + } + if (active_mode.empty() || active_mode == "primary") return false; + + auto* names = printer_cfg.option("imex_mode_names"); + auto* tools = printer_cfg.option("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 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(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(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 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() { diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index fbba3f6825..83bcfdd93f 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -1,9 +1,11 @@ #ifndef __part_plate_hpp_ #define __part_plate_hpp_ +#include #include #include #include +#include #include #include #include @@ -143,7 +145,11 @@ private: std::vector m_imex_collision_overlay; // red-orange rendered fill for danger strips std::vector m_imex_margin_overlay; // amber advisory bands just inside collision strips std::optional m_imex_primary_zone_box; // primary zone extents in mm; empty when IDEX/IQEX is off/primary-only + std::map 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> 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 get_imex_head_filament_map() const; + void set_imex_head_filament_map(const std::map& 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>& 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(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 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(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index ebb509a33c..b29b32ba77 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -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 collect_imex_warnings(PartPlate* plate) const size_t max_tool = std::min(bundle->filament_presets.size(), MAXIMUM_EXTRUDER_NUMBER); std::vector 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 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("curr_bed_type"); @@ -9994,8 +9998,9 @@ static std::vector 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("physical_extruder_map"); + if (!pem || pem->values.size() < 2) + pem = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("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("physical_extruder_map"); + if (!pem || pem->values.size() < 2) + pem = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("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(); } diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 83c5dd6b75..1436e141c7 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -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); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 8d2eed550a..6f5b6655bb 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4483,6 +4483,9 @@ public: // Parse "idx:P,idx:C,idx:M" → map // 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 parse_tool_states(const std::string& s) { std::map 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&) { diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 38ff543336..118548fee5 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -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 diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp new file mode 100644 index 0000000000..248a1feec2 --- /dev/null +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -0,0 +1,283 @@ +#include + +#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 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 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 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 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 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 override_on_5 = {{5, 7}}; + REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6); +}