From 76d9b8bac045afb8f9b89464a1da730df48c703c Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 27 Aug 2026 13:17:19 +0800 Subject: [PATCH] Publish 3MF: support mixed filaments and per-extruder slot selection - Publish mixed-filament slots as whole units: serialize the filament_mixed_* definition into project_config on import, grow the receiver's parallel arrays in lockstep, and report unappliable definitions as skipped instead of dropping them silently - Per-extruder printer selection: one inner tab per extruder, rows keyed by full "#N" ids; single-extruder receivers collapse variants onto their slot (first applied, rest skipped), multi-extruder receivers override element-wise - New per-slot "Enable" toggle gating what gets published; enabling a mix auto-enables + Full Publishes its components - Mixed page previews: fixed-size ratio bar, ternary triangle (3 components) and Material Ratio vs Model Height graph (gradients), always visible regardless of Enable - Tab strip shows full swatch compositions with adjustable spacing; barycentric helpers shared via FilamentBitmapUtils --- src/libslic3r/PresetBundle.cpp | 98 +- src/libslic3r/PublishSettings.cpp | 17 + src/libslic3r/PublishSettings.hpp | 7 + src/slic3r/GUI/FilamentBitmapUtils.cpp | 41 +- src/slic3r/GUI/FilamentBitmapUtils.hpp | 16 + src/slic3r/GUI/MixedFilamentDialog.cpp | 48 +- src/slic3r/GUI/PublishSettingsDialog.cpp | 942 ++++++++++++++++-- src/slic3r/GUI/PublishSettingsDialog.hpp | 52 +- src/slic3r/GUI/Widgets/TabCtrl.cpp | 138 ++- src/slic3r/GUI/Widgets/TabCtrl.hpp | 50 +- tests/libslic3r/test_3mf.cpp | 52 + .../libslic3r/test_preset_bundle_loading.cpp | 249 +++++ 12 files changed, 1474 insertions(+), 236 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index b66c0a8587..9157987b8a 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5291,6 +5291,11 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, const std::set printer_option_set(printer_options.begin(), printer_options.end()); std::set contract_excluded_keys; auto apply_published = [&](DynamicPrintConfig& target, const std::set* allowlist) { + // Single-extruder receivers collapse a base key's per-extruder "#N" variants onto + // their single slot: only the first serialized variant of a base key is applied + // (the author's "left or right" whichever came first), the rest are reported as + // skipped. Per-target, so the process and printer passes each track their own bases. + std::set collapsed_bases; for (const std::string& key : published_config->published_keys) { if (applied_keys.count(key) != 0) continue; // already applied @@ -5309,7 +5314,7 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, if (src_opt == nullptr) continue; // key not present in the loaded config; record later if (src_opt->is_vector()) { - const ConfigOption* dst_opt = target.option(base_key); + ConfigOption* dst_opt = target.option(base_key); if (dst_opt == nullptr || !dst_opt->is_vector()) continue; // cannot apply; will be reported as skipped // Type mismatch: ConfigOptionVector::set() throws ConfigurationError on a @@ -5321,7 +5326,7 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, if (dst_opt->type() != src_opt->type()) continue; // A '#N' variant key (e.g. per-extruder retraction_length#2) applies one - // element, so the index only needs to be in range on both sides - the + // element, so the index only needs to be in range on the author's side - the // receiver may have a different extruder count than the author. Out-of-range // indices are skipped (set_at would otherwise resize the receiver's vector). if (key.size() > base_key.size()) { @@ -5341,16 +5346,33 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, break; } } - if (!valid || idx >= static_cast(src_opt)->size() || - idx >= static_cast(dst_opt)->size()) - continue; // malformed or out-of-range variant: cannot apply; reported as skipped + const size_t src_size = static_cast(src_opt)->size(); + if (!valid || idx >= src_size) + continue; // malformed or out-of-range on the author's side: cannot apply; reported as skipped + const size_t dst_size = static_cast(dst_opt)->size(); + if (dst_size == 1) { + // Single-extruder receiver: collapse the author's per-extruder slots + // onto the receiver's single slot. Only the first serialized variant + // of a base key is applied (the author's "left or right" whichever was + // published first); later variants of the same base are reported as + // skipped, mirroring the receiver's single extruder. + if (collapsed_bases.count(base_key) != 0) + continue; + collapsed_bases.insert(base_key); + static_cast(dst_opt)->set_at(src_opt, 0, idx); + } else { + if (idx >= dst_size) + continue; // out-of-range variant: cannot apply; reported as skipped + target.apply_only(config, {key}, true); + } } else if (static_cast(src_opt)->size() != static_cast(dst_opt)->size()) { // Whole-vector base key: the receiver must have a matching vector size, // otherwise applying would overwrite a different number of elements. continue; // cannot apply; will be reported as skipped + } else { + target.apply_only(config, {key}, true); } - target.apply_only(config, {key}, true); applied_keys.insert(key); } else { // A scalar key cannot carry a '#N' suffix; a hand-crafted file listing one @@ -5688,6 +5710,30 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, proj_nozzle_map->values.resize(target_slots, 0); if (proj_volume_map && proj_volume_map->values.size() < target_slots) proj_volume_map->values.resize(target_slots, static_cast(NozzleVolumeType::nvtStandard)); + // The mixed-color project arrays are parallel per-slot like filament_colour; + // grow them in lockstep so a published mix slot has room for its definition. + // Defaults mirror set_num_filaments (false / empty string). + if (auto* opt = this->project_config.opt("filament_is_mixed")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_components")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_sublayer_ratios")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_range")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_curve")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_per_part")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); if (this->ams_multi_color_filment.size() < target_slots) this->ams_multi_color_filment.resize(target_slots); for (size_t slot = old_colour_count; slot < target_slots; ++slot) { @@ -5995,7 +6041,13 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // Colour is slot-scoped and independent of the type gate; it is also synced // into project_config for GUI rendering. if (entry.publish_color && !entry.color.empty()) { - if (recv != nullptr) { + // A mixed-definition entry carries the mix's blended colour for the + // swatch only: never write it into the slot's (possibly shared) preset + // config, only into the project-level colour arrays. + const bool is_mixed_entry = std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) { + return publish_mixed_keys().count(publish_base_key(key)) != 0; + }); + if (!is_mixed_entry && recv != nullptr) { // Create the key when the target preset lacks it: the colour is a // requirement, not an override. if (ConfigOptionStrings* colour = write_config.opt("filament_colour", true)) { @@ -6016,8 +6068,36 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, } } - if (apply_slot && recv != nullptr) - apply_slot_keys(write_config, entry.keys, entry.slot, material_label); + if (apply_slot && recv != nullptr) { + // Mixed-color definition keys live in project_config (parallel per-slot + // arrays), not in a filament preset; route them there. Normal keys keep + // the per-slot preset path below. + const std::set& mixed_keys = publish_mixed_keys(); + std::vector preset_keys; + for (const std::string& key : entry.keys) { + const std::string base_key = publish_base_key(key); + if (mixed_keys.count(base_key) != 0) { + const ConfigOption* src_opt = config.option(base_key); + if (src_opt != nullptr && src_opt->is_vector() && entry.slot >= 0 && + entry.slot < static_cast(static_cast(src_opt)->size())) { + if (ConfigOption* dst_opt = this->project_config.option(base_key)) { + if (dst_opt->is_vector() && dst_opt->type() == src_opt->type() && + slot < static_cast(dst_opt)->size()) { + static_cast(dst_opt)->set_at(src_opt, slot, entry.slot); + material_applied = true; + continue; + } + } + } + // The slot (or its arrays) could not be written: report rather + // than drop the mix silently. + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + preset_keys.emplace_back(key); + } + apply_slot_keys(write_config, preset_keys, entry.slot, material_label); + } } } } diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp index 3a3d241e81..c4c71f9436 100644 --- a/src/libslic3r/PublishSettings.cpp +++ b/src/libslic3r/PublishSettings.cpp @@ -83,6 +83,23 @@ const std::set& publish_structural_keys() return structural_keys; } +const std::set& publish_mixed_keys() +{ + // Must match PresetBundle's s_project_options mixed-color group (PresetBundle.cpp): these + // are project-level parallel per-slot arrays, not filament-preset options, so the import + // material pass applies them into project_config instead of a filament preset config. + static const std::set mixed_keys = { + "filament_is_mixed", + "filament_mixed_components", + "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", + "filament_mixed_gradient_range", + "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + return mixed_keys; +} + // The printer tab's "Retraction" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. // KEEP IN SYNC with that optgroup: the published-3MF printer allowlist is built from these // lists, so any key shown there must be publishable here (and vice versa). diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp index d10472ce71..038a6eca11 100644 --- a/src/libslic3r/PublishSettings.hpp +++ b/src/libslic3r/PublishSettings.hpp @@ -15,6 +15,13 @@ std::string publish_base_key(const std::string &key); // filter_published_config because 3MF validation needs it - exported, never applied. const std::set& publish_structural_keys(); +// The mixed-color filament project keys (parallel per-slot arrays, see PresetBundle's +// s_project_options): a mixed slot's full definition - which slots it blends, the sublayer +// ratios and the optional Z-gradient description. A published mixed slot always serializes +// these keys; on import they are applied into the receiver's project_config (not a filament +// preset), so the mix survives the round-trip. +const std::set& publish_mixed_keys(); + // One row of the printer tab's "Retraction" / "Z-Hop" optgroups (key + tab icon id), kept // together so the tab can later be migrated onto these lists. struct PublishablePrinterOption { diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 1f51fc79b3..23b14c385b 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -11,6 +11,45 @@ namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker. +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) +{ + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); +} + +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double total = tri_signed_area2(v0, v1, v2); + if (std::abs(total) < 1e-9) return false; + double s0 = tri_signed_area2(p, v1, v2) / total; + double s1 = tri_signed_area2(v0, p, v2) / total; + double s2 = 1.0 - s0 - s1; + return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; +} + +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2) +{ + double total = std::abs(tri_signed_area2(v0, v1, v2)); + if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } + w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; + w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; + w2 = 1.0 - w0 - w1; + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } +} + +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + return {w0 * v0.x + w1 * v1.x + w2 * v2.x, + w0 * v0.y + w1 * v1.y + w2 * v2.y}; +} + void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, const wxColour& to) { if (rect.width <= 0 || rect.height <= 0) return; @@ -73,7 +112,7 @@ std::vector sample_gradient_ramp(const wxColour& first, // Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in // ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's // endpoints, otherwise the 0.10 -> 0.90 default. -static Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) { const auto* curve_opt = cfg.option("filament_mixed_gradient_curve"); if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 11696f3401..fc6eb1f9dd 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -13,6 +13,16 @@ namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; } namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker, shared by the mixed-filament +// editor and the Publish dialog's read-only definition preview. +struct TriPoint { double x, y; }; + +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c); +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2); +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); + // Fills a rect with a west->east linear gradient by drawing solid 1px columns. // Use instead of wxDC::GradientFillLinear, whose CoreGraphics (CGShading) backend // fails to render on some macOS builds; solid fills are unaffected. @@ -51,6 +61,12 @@ std::vector sample_gradient_ramp(const wxColour& first, // destination's height in pixels. std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); +// Resolve the curve a gradient slot is sampled with: the custom curve wins when it has at +// least two points, otherwise a straight line between gradient_range's endpoints, otherwise +// the 0.10 -> 0.90 default. Mirrors the slicer's ToolOrdering fallback so every preview +// agrees with what gets sliced. Always returns a two-point curve. +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot); + // Fill rect with a ramp, ramp.front() along the bottom edge. void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp); diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 902c12d27b..c1cbcc7450 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -919,52 +919,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() } // ---- Triangle (ternary) ratio picker ---- - -// Barycentric coordinate utilities -struct TriPoint { double x, y; }; - -static double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) -{ - return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); -} - -static bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double total = tri_signed_area2(v0, v1, v2); - if (std::abs(total) < 1e-9) return false; - double s0 = tri_signed_area2(p, v1, v2) / total; - double s1 = tri_signed_area2(v0, p, v2) / total; - double s2 = 1.0 - s0 - s1; - return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; -} - -static void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, - double& w0, double& w1, double& w2) -{ - double total = std::abs(tri_signed_area2(v0, v1, v2)); - if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } - w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; - w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; - w2 = 1.0 - w0 - w1; - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } -} - -static TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double w0, w1, w2; - tri_barycentric(p, v0, v1, v2, w0, w1, w2); - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } - return {w0 * v0.x + w1 * v1.x + w2 * v2.x, - w0 * v0.y + w1 * v1.y + w2 * v2.y}; -} +// The barycentric utilities (TriPoint, tri_contains, tri_barycentric, tri_clamp) live in +// FilamentBitmapUtils so the Publish dialog can mirror this picker read-only. wxBoxSizer* MixedFilamentDialog::create_triangle_picker() { diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index 70a6cc96ae..262fdbe356 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -6,6 +6,7 @@ #include "I18N.hpp" #include "Tab.hpp" #include "ConfigValueFormatter.hpp" +#include "FilamentBitmapUtils.hpp" #include "Widgets/Label.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/DialogButtons.hpp" @@ -15,11 +16,21 @@ #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PublishSettings.hpp" +#include "libslic3r/FilamentMixer.hpp" #include #include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include namespace Slic3r { namespace GUI { namespace { @@ -90,8 +101,230 @@ wxString material_title(size_t slot, const PresetBundle* bundle, const DynamicPr return _L("Material"); } +// The component slots of a mixed filament ("1,2,3"), 1-based, or empty when out of range. +std::vector mixed_slot_components(const DynamicPrintConfig& full, size_t slot) +{ + const auto* comp_opt = full.opt("filament_mixed_components"); + if (comp_opt == nullptr || slot >= comp_opt->size()) + return {}; + return parse_mixed_components(comp_opt->values[slot]); +} + +// Human-readable label of a mixed filament, mirroring the sidebar's mixed filament rows: the +// 1-based component slot numbers with their blend percentages (or a "->" gradient arrow), +// e.g. "1 (60%) + 2 (40%)". Used as the slot's tab/header title in place of a preset name. +wxString mixed_filament_label(const DynamicPrintConfig& full, size_t slot) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return _L("Mixed filament"); + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + wxString label; + for (size_t i = 0; i < comps.size(); ++i) { + if (i > 0) + label += is_gradient ? wxString::FromUTF8(" \u2192 ") : wxString::FromUTF8(" + "); + label += wxString::Format("%u", comps[i]); + if (!is_gradient) { + double ratio = 100.0 / comps.size(); + if (ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (i < rs.size()) + ratio = rs[i] * 100.0; + } + label += wxString::Format(" (%d%%)", int(ratio + 0.5)); + } + } + return label; +} + +// Blended representative colour of a mixed slot, computed exactly like the sidebar's swatches +// (recompute_mixed_slot_colors): sublayer slots blend by their ratios, gradient slots by their +// two end colours, broken references fall back to grey. +wxColour mixed_filament_blend_color(const DynamicPrintConfig& full, size_t slot) +{ + std::vector colors; + if (const auto* colours = full.opt("filament_colour")) { + colors.reserve(colours->values.size()); + for (const std::string& hex : colours->values) { + const wxColour c(hex); + colors.push_back(c.IsOk() ? c : wxColour(0, 0, 0)); + } + } + while (colors.size() <= slot) + colors.push_back(wxColour("#D9D9D9")); + recompute_mixed_slot_colors(colors, full); + return slot < colors.size() ? colors[slot] : wxColour("#D9D9D9"); +} + +// Tab-strip bitmap for a mixed slot: the mix's own chip, then its component swatches each +// followed by their percent share (or a "->" arrow for gradients), mirroring the main GUI's +// sidebar rows - e.g. "[3 purple]: [1 red] 50% + [2 blue] 50%". The whole composition is one +// bitmap because a TabCtrl item cannot interleave images into its text; the tab's text is +// therefore empty. Transparent background like the other chips. +wxBitmap mixed_filament_tab_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return wxNullBitmap; + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + std::vector ratio_pct(comps.size(), 100.0 / comps.size()); + if (!is_gradient && ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + for (size_t i = 0; i < rs.size() && i < comps.size(); ++i) + ratio_pct[i] = rs[i] * 100.0; + } + + const auto* colours = full.opt("filament_colour"); + const wxString lead_sep = wxString::FromUTF8(":"); + const wxString comp_sep = is_gradient ? wxString::FromUTF8("\u2192") : wxString::FromUTF8("+"); + + // Phase 1 (layout): render every swatch and measure every text piece so the composite + // width is known before drawing; the dummy bitmap keeps GetTextExtent reliable. + struct Piece + { + enum Kind { Swatch, Text } kind{Text}; + wxBitmap bmp; + wxString text; + }; + std::vector pieces; + bool has_lead = false; + wxBitmap dummy(1, 1); + wxMemoryDC measure_dc; + measure_dc.SelectObject(dummy); + measure_dc.SetFont(::Label::Body_12); + const int gap = wxWindow::FromDIP(4, nullptr); + + auto push_swatch = [&](const std::string& hex, const std::string& label) { + wxBitmap* icon = get_extruder_color_icon(hex, label, swatch_sz, swatch_sz); + if (icon == nullptr) + return; + pieces.push_back({Piece::Swatch, *icon, wxString()}); + }; + auto push_text = [&](const wxString& text) { pieces.push_back({Piece::Text, wxNullBitmap, text}); }; + + { + const wxColour blend = mixed_filament_blend_color(full, slot); + const std::string blend_hex = blend.IsOk() ? + std::string(wxString::Format("#%02X%02X%02X", blend.Red(), blend.Green(), blend.Blue()).ToUTF8()) : + std::string("#808080"); + const size_t before = pieces.size(); + push_swatch(blend_hex, std::to_string(slot + 1)); + has_lead = pieces.size() > before; + } + for (size_t ci = 0; ci < comps.size(); ++ci) { + if (pieces.empty()) + break; + push_text(has_lead && pieces.size() == 1 ? lead_sep : comp_sep); // lead chip may have failed to render + std::string hex = "#D9D9D9"; + if (colours != nullptr && comps[ci] >= 1 && comps[ci] <= colours->size()) + hex = colours->values[comps[ci] - 1]; + const size_t before = pieces.size(); + push_swatch(hex, std::to_string(comps[ci])); + if (pieces.size() == before) + break; // swatch failed: stop cleanly before an orphaned separator/percent pair + if (!is_gradient) { + push_text(wxString::Format("%d%%", int(ratio_pct[ci] + 0.5))); + } + } + if (pieces.empty()) + return wxNullBitmap; + + int width = 0; + for (const Piece& p : pieces) + width += (p.kind == Piece::Swatch ? swatch_sz : measure_dc.GetTextExtent(p.text).x + gap); + + // Phase 2 (draw): transparent background like the page-header chips. + wxBitmap composite(width, swatch_sz); + wxMemoryDC memdc; +#ifdef __WXOSX__ + composite.UseAlpha(); + memdc.SelectObject(composite); +#else + { + wxImage img(width, swatch_sz); + img.InitAlpha(); + memset(img.GetAlpha(), 0, width * swatch_sz); + composite = wxBitmap(std::move(img)); + } + memdc.SelectObject(composite); +#endif + { +#ifdef __WXMSW__ + wxGCDC dc(memdc); +#else + wxDC& dc = memdc; +#endif + dc.SetBackgroundMode(wxTRANSPARENT); + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#262E30"))); + int x = 0; + for (const Piece& p : pieces) { + if (p.kind == Piece::Swatch) { + dc.DrawBitmap(p.bmp, x, 0); + x += swatch_sz; + } else { + const wxSize tsz = measure_dc.GetTextExtent(p.text); + dc.DrawText(p.text, x + gap / 2, (swatch_sz - tsz.y) / 2); + x += tsz.x + gap; + } + } + } + memdc.SelectObject(wxNullBitmap); + return composite; +} + } // namespace +PublishSettingsDialog::MixedVisualSpec PublishSettingsDialog::make_mixed_visual_spec(const DynamicPrintConfig& full, size_t slot) +{ + MixedVisualSpec spec; + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return spec; + + const auto* grad_opt = full.opt("filament_mixed_gradient"); + spec.is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + + const auto* colours = full.opt("filament_colour"); + for (unsigned int cid : comps) { + std::string hex = "#D9D9D9"; + if (colours != nullptr && cid >= 1 && cid <= colours->size()) + hex = colours->values[cid - 1]; + const wxColour c(hex); + spec.component_colours.push_back(c.IsOk() ? c : wxColour("#D9D9D9")); + } + + if (!spec.is_gradient) { + // Sublayer shares; parse_mixed_ratios already falls back to equal shares and + // normalizes to sum 1. + spec.ratios.assign(comps.size(), 1.0 / comps.size()); + if (const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios")) + if (slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (rs.size() == comps.size()) + spec.ratios = rs; + } + if (comps.size() == 3) + spec.tri_weights = spec.ratios; // the picker's barycentric shares + } else { + const Slic3r::GradientCurve curve = mixed_gradient_curve(full, slot); + constexpr int kSamples = 64; + for (int i = 0; i <= kSamples; ++i) { + const double t = double(i) / kSamples; + spec.gradient_samples.emplace_back(t, sample_gradient_curve(curve, t)); + } + for (const Slic3r::GradientAnchor& anchor : curve.points) + spec.gradient_anchors.emplace_back(anchor.x, anchor.y); + } + + spec.valid = true; + return spec; +} + PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent) : DPIDialog(parent ? parent : static_cast(wxGetApp().mainframe), wxID_ANY, @@ -206,9 +439,12 @@ void PublishSettingsDialog::fit_to_content() static const wxSize BASE{600, 500}; static const wxSize CAP{1300, 850}; - int strip = m_outer_tabs->buttons_best_width(); - for (const SectionGroup& section : m_sections) - strip = std::max(strip, section.tabs->buttons_best_width()); + int strip = m_outer_tabs->GetFullSize(); + for (const SectionGroup& section : m_sections) { + strip = std::max(strip, section.tabs->GetFullSize()); + if (section.mixed_tabs != nullptr) + strip = std::max(strip, section.mixed_tabs->GetFullSize()); + } // Minimum width: whatever keeps every tab visible (never below the base). strip is device // pixels (Button min sizes); BASE/CAP are DIP and converted over. @@ -233,9 +469,9 @@ void PublishSettingsDialog::build_option_model() { // Structural / non-publishable keys, shared with the published-3MF overlay path. const std::set& denylist = publish_structural_keys(); - // Base keys already added in the print/printer sections. Printer rows share this set: - // per-extruder "#N" variants collapse to the first occurrence (acceptable MVP; the - // per-extruder context is lost in the UI). + // Base keys already added in the print section (dedup across pages/optgroups). The printer + // section keeps its own printer_added set keyed by the full per-extruder "#N" opt_id, so + // every extruder gets its own row (see Phase 1 below). std::set added; PresetBundle* bundle = wxGetApp().preset_bundle; @@ -244,6 +480,7 @@ void PublishSettingsDialog::build_option_model() m_info_nonsel = _L("No selected items..."); m_info_allsel = _L("All items selected..."); m_info_empty = _L("No matching items..."); + m_info_mix = _L("Mixed filament - published as a whole when \"Enable\" above is selected"); // Tab order differs from Section's enum order (Print, Printer, Material): the dialog // presents Printer, Filament, Process. @@ -272,16 +509,33 @@ void PublishSettingsDialog::build_option_model() }; // --- Phase 1: printer per-extruder retraction settings (first, mirroring the sidebar's - // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. + // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. One inner tab per + // extruder (e.g. "Left Extruder"/"Right Extruder" via Tab::translate_category), each holding + // that extruder's Retraction and Z-Hop rows with per-extruder "#N" values. { size_t g = section_group_for(Section::Printer); - category_index_for(_L("Extruder"), Section::Printer, g, 0); + std::set printer_added; for (Tab* tab : wxGetApp().tabs_list) { if (tab->m_type != Preset::TYPE_PRINTER) continue; for (const PageShp& page : tab->m_pages) { if (!page->title().StartsWith("Extruder")) continue; + // The extruder index of this page: its options are appended with the same + // "#N" opt_index (opt.second.second), so derive the tab's index from the first + // allowlisted option; skip the page when none is found (defensive). + int extruder_idx = -1; + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") + continue; + for (const auto& opt : optgroup->opt_map()) + if (extruder_idx < 0) + extruder_idx = opt.second.second; + if (extruder_idx >= 0) + break; + } + if (extruder_idx < 0) + continue; const wxString page_title = Tab::translate_category(page->title(), tab->m_type); for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { // Allowlist on the untranslated optgroup title; the "Retraction when @@ -292,17 +546,17 @@ void PublishSettingsDialog::build_option_model() for (const auto& opt : optgroup->opt_map()) { const std::string& opt_id = opt.first; const std::string& pure_key = opt.second.first; - // Per-extruder "#N" variants collapse to the first base key. The row - // stores the base key; GetPublishedKeys() later expands it back to one - // "#N" entry per extruder so the load side can apply per-extruder values. - if (!added.insert(pure_key).second) + // Rows are keyed by the full per-extruder "#N" opt_id so each extruder + // tab publishes its own value; GetPublishedKeys() emits the checked rows + // as-is. + if (!printer_added.insert(opt_id).second) continue; wxString label, value, unit; if (!option_text(opt_id, pure_key, label, value, unit)) continue; - size_t cat_index = category_index_for(_L("Extruder"), Section::Printer, g, 0); + size_t cat_index = category_index_for(page_title, Section::Printer, g, size_t(extruder_idx)); size_t sub_index = subcategory_index_for(cat_index, subcategory, optgroup->icon); - add_row_ui(pure_key, label, value, unit, cat_index, sub_index); + add_row_ui(opt_id, label, value, unit, cat_index, sub_index); } } } @@ -328,12 +582,28 @@ void PublishSettingsDialog::build_option_model() } if (overrides_page != nullptr) { + // Mixed-color slots are virtual: they carry no per-key Material/Retraction + // settings; their "Enable" toggle always embeds the mix definition (components, + // ratios, gradient). Detect them via the project-level flag. + const ConfigOptionBools* is_mixed_opt = full.opt("filament_is_mixed"); // One section per filament slot (a 4-slot printer shows 4 pages), each // disambiguated by its colour chip and slot identity while showing the bare name. for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) { + const bool is_mixed = is_mixed_opt != nullptr && slot < is_mixed_opt->size() && is_mixed_opt->values[slot]; const PublishMaterialIdentity identity = material_identity(slot, full); - const wxString title = material_title(slot, bundle, full); - const size_t category_index = category_index_for(title, Section::Material, g, slot, identity); + // A mixed slot's title is its component composition (e.g. "1 (60%) + 2 + // (40%)"), not the cloned preset's name shown in the main GUI. + const wxString title = is_mixed ? mixed_filament_label(full, slot) : material_title(slot, bundle, full); + const size_t category_index = category_index_for(title, Section::Material, g, slot, identity, is_mixed); + + if (is_mixed) { + // A mixed slot publishes as one unit (its definition); nothing to select + // per-key. Its component filaments are auto-enabled + Full Published when + // "Enable" is checked (see on_enable_toggle). Its page still shows what + // would be published: a ratio bar, or the gradient graph. + add_mixed_visual(category_index, make_mixed_visual_spec(full, slot)); + continue; + } // Material requirement rows: an optional filament colour and/or a // vendor-agnostic material type for this slot, in their own optgroup so they @@ -414,25 +684,37 @@ void PublishSettingsDialog::build_option_model() } } - // Pre-check the dirty (modified) settings and mark them bold (base-key match, across all + // Pre-check the dirty (modified) settings and mark them bold (per-slot match, across all // sections; collect_dirty_settings_keys unions the prints, printers and filaments). - std::set dirty_base; + // Dirty keys carry a "#N" per-extruder/per-slot suffix (deep_diff), so the base key alone + // cannot distinguish which extruder/filament slot changed: match the row's exact key, and + // for material rows (base key + per-slot value) the base key plus the section's slot. + std::set dirty_keys; for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) - dirty_base.insert(publish_base_key(key)); + dirty_keys.insert(key); for (Row& row : m_rows) { // The Color/Type requirement rows are not "dirty overrides": never auto-checked. if (row.kind != RowKind::Setting) continue; - std::string base = publish_base_key(row.key); - row.dirty = dirty_base.count(base) > 0; + bool dirty = dirty_keys.count(row.key) != 0; + if (!dirty && row.section == Section::Material) + dirty = dirty_keys.count(publish_base_key(row.key) + "#" + std::to_string(m_categories[row.inner_index].filament_slot)) != 0; + row.dirty = dirty; if (row.dirty) { row.check->SetValue(true); set_row_bold(row, true); } } - // Wire the "Full Publish" checkboxes: toggling one disables/enables the material's rows. - // Bind by index so the lambda stays valid even if the vector is reallocated later. + // Wire the "Enable" checkboxes: toggling one reveals/hides the slot's settings below the + // header (and, for a mixed slot, auto-selects its components). Bind by index so the lambda + // stays valid even if the vector is reallocated later. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].enable_check != nullptr) + m_categories[c].enable_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_enable_toggle(c); }); + + // Wire the "Full Publish" checkboxes (physical slots): toggling one disables/enables the + // material's rows. for (size_t c = 0; c < m_categories.size(); ++c) if (m_categories[c].full_check != nullptr) m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); @@ -487,6 +769,18 @@ size_t PublishSettingsDialog::section_group_for(Section kind) section.tabs->SetFont(Label::Body_14); section.tabs->SetBackgroundColour(GetBackgroundColour()); page_sizer->Add(section.tabs, 0, wxEXPAND); + // Mixed-color filament slots get a second tab strip below the physical filament tabs; only + // the Material section has them. + if (kind == Section::Material) { + section.mixed_tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + section.mixed_tabs->SetFont(Label::Body_14); + section.mixed_tabs->SetBackgroundColour(GetBackgroundColour()); + // The mixed tabs carry full swatch compositions: give them extra room to breathe so + // neighbouring compositions do not read as one long row (must precede AppendItem). + section.mixed_tabs->SetItemSpace(FromDIP(5)); + page_sizer->Add(section.mixed_tabs, 0, wxEXPAND | wxTOP, FromDIP(2)); + section.mixed_tabs->Hide(); + } section.page_host = new wxPanel(section.page, wxID_ANY); section.page_host->SetBackgroundColour(GetBackgroundColour()); section.page_host_sizer = new wxBoxSizer(wxVERTICAL); @@ -505,14 +799,20 @@ size_t PublishSettingsDialog::section_group_for(Section kind) } size_t PublishSettingsDialog::category_index_for( - const wxString& title, Section section, size_t group, size_t source_index, const PublishMaterialIdentity& identity) + const wxString& title, Section section, size_t group, size_t source_index, const PublishMaterialIdentity& identity, bool is_mixed) { - for (size_t i : m_sections[group].categories) { - Category& existing = m_categories[i]; - if (existing.title == title && existing.section == section && existing.source_index == source_index && - existing.filament_id == identity.id && existing.filament_type == identity.type && existing.filament_vendor == identity.vendor) + // Dedup across both tab rows (physical + mixed); mixed slots are never duplicated anyway. + auto match = [&](const Category& existing) { + return existing.title == title && existing.section == section && existing.source_index == source_index && + existing.filament_id == identity.id && existing.filament_type == identity.type && + existing.filament_vendor == identity.vendor; + }; + for (size_t i : m_sections[group].categories) + if (match(m_categories[i])) + return i; + for (size_t i : m_sections[group].mixed_categories) + if (match(m_categories[i])) return i; - } Category category; category.title = title; @@ -523,29 +823,58 @@ size_t PublishSettingsDialog::category_index_for( category.filament_vendor = identity.vendor; category.filament_id = identity.id; category.filament_slot = source_index; + category.is_mixed = is_mixed; category.page = new wxPanel(m_sections[group].page_host, wxID_ANY); category.page->SetBackgroundColour(GetBackgroundColour()); auto* page_sizer = new wxBoxSizer(wxVERTICAL); - // The slot's colour chip decorates both the section header and the inner tab. + // The physical slot's colour chip decorates the page header and the inner tab; it carries + // the 1-based slot number, mirroring the main GUI's filament swatches. A mixed slot has no + // header at all: its page is just the Enable toggle above the (always visible) definition + // preview - the identification lives in the tab strip's composition bitmap. std::string hex; - if (section == Section::Material) - hex = filament_color_hex(wxGetApp().preset_bundle->full_config(), source_index); + std::string chip_label; + if (section == Section::Material && !is_mixed) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + hex = filament_color_hex(full, source_index); + chip_label = std::to_string(source_index + 1); + } if (section == Section::Material) { - auto* header_sizer = new wxBoxSizer(wxHORIZONTAL); - if (wxBitmap* chip = get_extruder_color_icon(hex, "", FromDIP(12), FromDIP(12))) { - category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); - header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + if (is_mixed) { + // No chip/title: the lone "Enable" checkbox tops the page. + auto* enable_sizer = new wxBoxSizer(wxHORIZONTAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this mixed filament and enable + Full Publish its component filaments")); + enable_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL); + page_sizer->Add(enable_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + } else { + // Line 1: [chip] [title] [Enable]. The Enable checkbox gates the whole slot: while + // it is unchecked nothing below the title is shown and nothing of it is published. + auto* header_sizer = new wxBoxSizer(wxHORIZONTAL); + if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); + header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } + category.title_label = new wxStaticText(category.page, wxID_ANY, title); + category.title_label->SetFont(Label::Head_14); + header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this filament slot in the 3MF file")); + header_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); + page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + + // Line 2: the "Full Publish" toggle, on its own line below the title (hidden until + // the slot is enabled). + auto* full_sizer = new wxBoxSizer(wxHORIZONTAL); + category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); + category.full_check->SetFont(Label::Body_13); + category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); + full_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL); + category.full_line_item = page_sizer->Add(full_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(2)); } - category.title_label = new wxStaticText(category.page, wxID_ANY, title); - category.title_label->SetFont(Label::Head_14); - header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); - category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); - category.full_check->SetFont(Label::Body_13); - category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); - header_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); - page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); } category.scroll = new wxScrolledWindow(category.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); @@ -555,28 +884,53 @@ size_t PublishSettingsDialog::category_index_for( category.scroll->SetSizer(category.list_sizer); category.scroll->DisableFocusFromKeyboard(); category.scroll->Bind(wxEVT_RIGHT_DOWN, &PublishSettingsDialog::show_menu, this); - category.info = new wxStaticText(category.scroll, wxID_ANY, m_info_empty); + category.info = new wxStaticText(category.scroll, wxID_ANY, is_mixed ? m_info_mix : m_info_empty); category.info->SetFont(Label::Body_13); category.list_sizer->Add(category.info, 1, wxALIGN_CENTER_HORIZONTAL | wxALL, FromDIP(10)); category.info->Hide(); page_sizer->Add(category.scroll, 1, wxEXPAND | wxALL, FromDIP(4)); + // A material slot starts disabled: its rows (and its Full Publish line) stay hidden until + // "Enable" is checked. + if (section == Section::Material) + category.scroll->Hide(); category.page->SetSizer(page_sizer); category.page->Hide(); const size_t category_index = m_categories.size(); m_categories.push_back(std::move(category)); - m_sections[group].categories.push_back(category_index); + // Mixed slots live in a second tab row below the physical filament tabs. + if (is_mixed) + m_sections[group].mixed_categories.push_back(category_index); + else + m_sections[group].categories.push_back(category_index); if (section == Section::Material) { - if (wxBitmap* chip = get_extruder_color_icon(hex, "", FromDIP(12), FromDIP(12))) - m_sections[group].tabs->AppendItem(title, *chip); - else - m_sections[group].tabs->AppendItem(title); + TabCtrl* target = is_mixed ? m_sections[group].mixed_tabs : m_sections[group].tabs; + if (is_mixed) { + // The tab's whole composition (mix chip + components + percents) lives in one + // bitmap; the text is empty because a TabCtrl item cannot interleave images into + // its text. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full_cfg, source_index, FromDIP(20)); + if (tab_bmp.IsOk()) + target->AppendItem(wxString(), tab_bmp); + else + target->AppendItem(title); + } else if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + target->AppendItem(title, *chip); + } else { + target->AppendItem(title); + } } else { m_sections[group].tabs->AppendItem(title); } m_sections[group].page_host_sizer->Add(m_categories[category_index].page, 1, wxEXPAND); - if (m_sections[group].selected_inner < 0) + if (is_mixed) { + if (m_sections[group].selected_mixed < 0) + m_sections[group].selected_mixed = 0; + m_sections[group].mixed_tabs->Show(); + } else if (m_sections[group].selected_inner < 0) { m_sections[group].selected_inner = 0; + } return category_index; } @@ -636,7 +990,9 @@ void PublishSettingsDialog::add_row_ui(const std::string& key, current.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30"))); current.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit); if (kind == RowKind::Color && !value.IsEmpty()) { - if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), "", FromDIP(12), FromDIP(12))) { + // The colour swatch carries this slot's 1-based number, like the main GUI swatches. + const std::string chip_label = std::to_string(category.filament_slot + 1); + if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) { current.color_chip = new wxStaticBitmap(category.scroll, wxID_ANY, *chip); row_sizer->Add(current.color_chip, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); } @@ -661,6 +1017,320 @@ void PublishSettingsDialog::on_full_toggle(size_t category_index) m_rows[r].check->Enable(!full); } +void PublishSettingsDialog::on_enable_toggle(size_t category_index) +{ + Category& cat = m_categories[category_index]; + const bool enabled = cat.enable_check->GetValue(); + + // A published mixed filament needs its component filaments published too: mark the slots it + // uses as "Enable"d and Full Published so the mix's physical components always ship their + // identities. + if (cat.is_mixed && enabled) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + const std::vector components = mixed_slot_components(full, cat.filament_slot); + for (const unsigned int component : components) { + // Components are 1-based physical filament indices. + const size_t component_slot = size_t(component) - 1; + for (size_t c = 0; c < m_categories.size(); ++c) { + Category& comp_cat = m_categories[c]; + if (comp_cat.section != Section::Material || comp_cat.is_mixed || comp_cat.filament_slot != component_slot) + continue; + if (comp_cat.enable_check != nullptr) + comp_cat.enable_check->SetValue(true); + if (comp_cat.full_check != nullptr) + comp_cat.full_check->SetValue(true); + on_full_toggle(c); + } + } + } + + // Reveal/hide everything below the slot's header (rows, info, Full Publish line) and refresh + // the visibility of the auto-selected component slots. + apply_visibility(); + if (cat.page != nullptr) + cat.page->GetSizer()->Layout(); +} + +void PublishSettingsDialog::add_mixed_visual(size_t category_index, const MixedVisualSpec& spec) +{ + if (category_index >= m_categories.size() || !spec.valid) + return; + Category& category = m_categories[category_index]; + if (category.page == nullptr || category.page->GetSizer() == nullptr || category.scroll == nullptr || spec.component_colours.empty()) + return; + + auto* viz = new wxPanel(category.page, wxID_ANY); + viz->SetBackgroundStyle(wxBG_STYLE_PAINT); + // Per-panel fill-bitmap cache for the ternary branch; rebuilt only when size or colours + // change (shared_ptr keeps the lifetime independent of this method's locals). + struct TriCache + { + wxBitmap bmp; + wxSize sz{0, 0}; + wxColour c0, c1, c2; + }; + auto tri_cache = std::make_shared(); + // Theme colours and DIP metrics are resolved inside the paint handler so dark-mode toggles + // and DPI changes are picked up on the next repaint without any explicit listener. + viz->Bind(wxEVT_PAINT, [this, panel = viz, spec, tri_cache](wxPaintEvent&) { + const wxColour bg = StateColor::darkModeColorFor(*wxWHITE); + wxBufferedPaintDC pdc(panel); + pdc.SetBackground(wxBrush(bg)); + pdc.Clear(); + const wxRect rc = panel->GetClientRect(); + if (rc.width <= 0 || rc.height <= 0) + return; + + const size_t n = spec.component_colours.size(); + + if (spec.tri_weights.size() == 3 && n == 3 && !spec.is_gradient) { + // Ternary mix: a read-only miniature of the MixedFilamentDialog's triangle picker. + // Per-pixel barycentric fill is cached into a bitmap keyed on size + colours; the + // marker and labels are redrawn on top every paint. + const wxColour tri_bg = StateColor::darkModeColorFor(*wxWHITE); + const wxColour outline = StateColor::darkModeColorFor(wxColour("#CECECE")); + const wxColour ring = StateColor::darkModeColorFor(wxColour("#262E30")); + const wxColour label_c = StateColor::darkModeColorFor(wxColour(107, 107, 107)); // grey 700 + const double margin_dip = 24.0; + auto& cache = *tri_cache; + + auto vertices_for = [&](const wxSize& sz) -> std::tuple { + const double pw = sz.GetWidth(), ph = sz.GetHeight(); + const int margin = FromDIP(int(margin_dip)); + const double avail = std::min(pw, ph) - 2.0 * margin; + const double side = avail; + const double tri_h = side * std::sqrt(3.0) / 2.0; + const double cx = pw / 2.0; + const double top_y = (ph - tri_h) / 2.0; + return {{cx, top_y}, {cx - side / 2.0, top_y + tri_h}, {cx + side / 2.0, top_y + tri_h}}; + }; + + pdc.SetFont(::Label::Body_12); + const wxColour& c0 = spec.component_colours[0]; + const wxColour& c1 = spec.component_colours[1]; + const wxColour& c2 = spec.component_colours[2]; + + if (!cache.bmp.IsOk() || cache.sz != rc.GetSize() || cache.c0 != c0 || cache.c1 != c1 || cache.c2 != c2) { + auto [v0, v1, v2] = vertices_for(rc.GetSize()); + cache.bmp = wxBitmap(rc.width, rc.height, 32); + wxMemoryDC mdc(cache.bmp); + mdc.SetBrush(wxBrush(tri_bg)); + mdc.SetPen(*wxTRANSPARENT_PEN); + mdc.DrawRectangle(0, 0, rc.width, rc.height); + + const int min_y = int(std::min({v0.y, v1.y, v2.y})); + const int max_y = int(std::max({v0.y, v1.y, v2.y})); + const int min_x = int(std::min({v0.x, v1.x, v2.x})); + const int max_x = int(std::max({v0.x, v1.x, v2.x})); + for (int py = min_y; py <= max_y; ++py) + for (int px = min_x; px <= max_x; ++px) { + const TriPoint p = {double(px), double(py)}; + if (!tri_contains(p, v0, v1, v2)) + continue; + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + unsigned char mr, mg, mb; + if (w0 + w1 > 1e-6) { + float t01 = static_cast(w1 / (w0 + w1)); + Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), c1.Red(), c1.Green(), c1.Blue(), t01, &mr, &mg, + &mb); + Slic3r::filament_mixer_lerp(mr, mg, mb, c2.Red(), c2.Green(), c2.Blue(), static_cast(w2), &mr, &mg, &mb); + } else { + mr = c2.Red(); + mg = c2.Green(); + mb = c2.Blue(); + } + mdc.SetPen(wxPen(wxColour(mr, mg, mb))); + mdc.DrawPoint(px, py); + } + + mdc.SetPen(wxPen(outline, 1)); + mdc.SetBrush(*wxTRANSPARENT_BRUSH); + const wxPoint pts[3] = {{int(v0.x), int(v0.y)}, {int(v1.x), int(v1.y)}, {int(v2.x), int(v2.y)}}; + mdc.DrawPolygon(3, pts); + mdc.SelectObject(wxNullBitmap); + + cache.sz = rc.GetSize(); + cache.c0 = c0; + cache.c1 = c1; + cache.c2 = c2; + } + pdc.DrawBitmap(cache.bmp, 0, 0); + + // Published-ratio marker (read-only twin of the editor's drag handle). + { + auto [v0, v1, v2] = vertices_for(rc.GetSize()); + const double w0 = spec.tri_weights[0], w1 = spec.tri_weights[1], w2 = spec.tri_weights[2]; + const int hx = int(w0 * v0.x + w1 * v1.x + w2 * v2.x); + const int hy = int(w0 * v0.y + w1 * v1.y + w2 * v2.y); + pdc.SetBrush(*wxWHITE_BRUSH); + pdc.SetPen(wxPen(ring, FromDIP(2))); + pdc.DrawCircle(hx, hy, FromDIP(5)); + + // Percent label beside each vertex. + for (int i = 0; i < 3; ++i) { + const wxString text = wxString::Format("%d%%", int(std::lround(spec.tri_weights[i] * 100.0))); + const wxSize tsz = pdc.GetTextExtent(text); + const TriPoint vtx = (i == 0) ? v0 : (i == 1) ? v1 : v2; + int lx = int(vtx.x - tsz.GetWidth() / 2.0); + int ly = (i == 0) ? int(vtx.y - tsz.GetHeight()) : int(vtx.y + FromDIP(3)); + ly = std::clamp(ly, 0, rc.height - tsz.GetHeight()); + lx = std::clamp(lx, 0, rc.width - tsz.GetWidth()); + pdc.SetTextForeground(label_c); + pdc.DrawText(text, lx, ly); + } + } + } else if (!spec.is_gradient) { + // Stacked ratio bar: one solid segment per component, widths proportional to the + // published shares. Integer widths accumulate left to right; the last segment takes + // the rounding remainder so the bar always fills exactly. + std::vector shares = spec.ratios; + double total = 0.0; + for (double r : shares) + total += r; + if (shares.size() != n || total <= 0.0) { + shares.assign(n, 1.0 / n); + total = 1.0; + } + auto share_to_px = [&](double share_sum) { return rc.x + int(std::lround(share_sum / total * double(rc.width))); }; + std::vector segs(n); + int x0 = rc.x; + for (size_t i = 0; i < n; ++i) { + int x1 = rc.x + rc.width; + if (i + 1 < n) + x1 = share_to_px(std::accumulate(shares.begin(), shares.begin() + i + 1, 0.0)); + segs[i] = wxRect(x0, rc.y, std::max(1, x1 - x0), rc.height); + x0 = segs[i].GetRight() + 1; + } + + for (size_t i = 0; i < n; ++i) { + pdc.SetPen(*wxTRANSPARENT_PEN); + pdc.SetBrush(wxBrush(spec.component_colours[i])); + pdc.DrawRectangle(segs[i]); + } + pdc.SetBrush(*wxTRANSPARENT_BRUSH); + pdc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#ACACAC")), 1)); + pdc.DrawRectangle(rc); + + // Percent label centred in each segment wide enough to hold it. + pdc.SetFont(::Label::Body_12); + for (size_t i = 0; i < n; ++i) { + const wxString text = wxString::Format("%d%%", int(std::lround(shares[i] / total * 100.0))); + const wxSize tsz = pdc.GetTextExtent(text); + if (tsz.GetWidth() + FromDIP(4) > segs[i].GetWidth()) + continue; + // Label contrast follows the swatch itself, not the theme. + const wxColour& c = spec.component_colours[i]; + const double lum = 0.299 * c.Red() + 0.587 * c.Green() + 0.114 * c.Blue(); + pdc.SetTextForeground(lum > 140 ? wxColour("#262E30") : *wxWHITE); + pdc.DrawText(text, segs[i].x + (segs[i].GetWidth() - tsz.GetWidth()) / 2, rc.y + (rc.height - tsz.GetHeight()) / 2); + } + } else { + // Gradient: compact "Material Ratio" over "Model Height" graph, a read-only + // miniature of the GradientCurveEditor plot. Component order matches the config; + // the second component's curve is the mirror of the first's. + const wxColour grid_color = StateColor::darkModeColorFor(wxColour(238, 238, 238)); // grey 300 + const wxColour axis_color = StateColor::darkModeColorFor(wxColour(107, 107, 107)); // grey 700 + const wxColour label_muted = StateColor::darkModeColorFor(wxColour(107, 107, 107)); + const wxColour point_fill = StateColor::darkModeColorFor(*wxWHITE); + + const int pad_left = FromDIP(34); + const int pad_right = FromDIP(10); + const int pad_top = FromDIP(18); + const int pad_bottom = FromDIP(16); + const wxRect plot(rc.x + pad_left, rc.y + pad_top, std::max(1, rc.width - pad_left - pad_right), + std::max(1, rc.height - pad_top - pad_bottom)); + + constexpr int kGridDivisions = 5; + pdc.SetPen(wxPen(grid_color, 1)); + for (int i = 0; i <= kGridDivisions; ++i) { + const int gx = plot.x + plot.width * i / kGridDivisions; + const int gy = plot.y + plot.height * i / kGridDivisions; + pdc.DrawLine(gx, plot.y, gx, plot.y + plot.height); + pdc.DrawLine(plot.x, gy, plot.x + plot.width, gy); + } + + // Axes with small filled arrowheads along the plot's left and bottom edges. + const int arrow_len = FromDIP(7); + const int arrow_half = FromDIP(3); + pdc.SetPen(wxPen(axis_color, 1)); + pdc.SetBrush(wxBrush(axis_color)); + pdc.DrawLine(plot.x, plot.y + plot.height, plot.x, plot.y); + { + wxPoint tri[3] = {wxPoint(plot.x, plot.y - arrow_len), wxPoint(plot.x - arrow_half, plot.y), + wxPoint(plot.x + arrow_half, plot.y)}; + pdc.DrawPolygon(3, tri); + } + pdc.DrawLine(plot.x, plot.y + plot.height, plot.x + plot.width, plot.y + plot.height); + { + wxPoint tri[3] = {wxPoint(plot.x + plot.width + arrow_len, plot.y + plot.height), + wxPoint(plot.x + plot.width, plot.y + plot.height - arrow_half), + wxPoint(plot.x + plot.width, plot.y + plot.height + arrow_half)}; + pdc.DrawPolygon(3, tri); + } + + wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); + pdc.SetFont(label_font); + pdc.SetTextForeground(label_muted); + pdc.DrawText(_L("Material Ratio"), plot.x + FromDIP(4), plot.y - pdc.GetTextExtent(_L("Material Ratio")).GetHeight()); + const wxString height_title = _L("Model Height"); + pdc.DrawText(height_title, plot.x + plot.width - pdc.GetTextExtent(height_title).GetWidth(), plot.y + plot.height + FromDIP(2)); + + if (spec.gradient_samples.size() >= 2 && n >= 2) { + auto curve_point = [&](double t, double ratio) { + return wxPoint(plot.x + int(std::lround(t * plot.width)), plot.y + int(std::lround((1.0 - ratio) * plot.height))); + }; + // First component's ratio solid, its mirror dashed-free twin for the other. + const wxColour& col_a = spec.component_colours[0]; + const wxColour& col_b = spec.component_colours[1]; + std::vector pts_a, pts_b; + pts_a.reserve(spec.gradient_samples.size()); + pts_b.reserve(spec.gradient_samples.size()); + for (const auto& [t, r] : spec.gradient_samples) { + pts_a.push_back(curve_point(t, r)); + pts_b.push_back(curve_point(t, 1.0 - r)); + } + pdc.SetPen(wxPen(col_b, 2)); + for (size_t i = 0; i + 1 < pts_b.size(); ++i) + pdc.DrawLine(pts_b[i], pts_b[i + 1]); + pdc.SetPen(wxPen(col_a, 2)); + for (size_t i = 0; i + 1 < pts_a.size(); ++i) + pdc.DrawLine(pts_a[i], pts_a[i + 1]); + + // Control-point anchors of the stored curve on the first component's line. + pdc.SetBrush(wxBrush(point_fill)); + pdc.SetPen(wxPen(col_a, 1)); + for (const auto& [t, r] : spec.gradient_anchors) { + const wxPoint c = curve_point(t, r); + pdc.DrawCircle(c, FromDIP(3)); + } + } + } + }); + + // Fixed DIP size, left-aligned: the visualization keeps its proportions no matter how the + // dialog is resized (the paint handler draws into whatever client rect the panel ends up + // with, so nothing else has to change). + const int viz_h = spec.is_gradient ? 150 : (spec.tri_weights.size() == 3 ? 180 : 30); + const wxSize viz_sz(FromDIP(240), FromDIP(viz_h)); + viz->SetMinSize(viz_sz); + viz->SetMaxSize(viz_sz); + // Parented to the page right above the scroll area, so it is always shown with the tab: + // the "Enable" toggle keeps gating only the rows/info below, never this preview. + wxSizer* page_sizer = category.page->GetSizer(); + int scroll_idx = -1; + for (size_t i = 0; i < page_sizer->GetChildren().size(); ++i) + if (page_sizer->GetChildren()[i]->GetWindow() == category.scroll) { + scroll_idx = int(i); + break; + } + if (scroll_idx < 0) + page_sizer->Add(viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); // defensive: no scroll item + else + page_sizer->Insert(scroll_idx, viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); +} + void PublishSettingsDialog::set_row_bold(Row& row, bool bold) { // Rebase on the dialog's body font so clearing bold restores the exact original font. @@ -681,13 +1351,18 @@ void PublishSettingsDialog::show_outer_page(size_t section_index) SectionGroup& old_section = m_sections[m_selected_outer]; if (old_section.selected_inner >= 0 && old_section.selected_inner < static_cast(old_section.categories.size())) save_scroll_position(m_categories[old_section.categories[old_section.selected_inner]]); + if (old_section.selected_mixed >= 0 && old_section.selected_mixed < static_cast(old_section.mixed_categories.size())) + save_scroll_position(m_categories[old_section.mixed_categories[old_section.selected_mixed]]); m_sections[m_selected_outer].page->Hide(); } m_selected_outer = static_cast(section_index); SectionGroup& section = m_sections[section_index]; section.page->Show(); + // Restore whichever tab row (physical or mixed) was active. if (section.selected_inner >= 0) show_inner_page(section_index, section.selected_inner); + else if (section.selected_mixed >= 0) + show_mixed_page(section_index, section.selected_mixed); m_outer_host_sizer->Layout(); } @@ -702,11 +1377,43 @@ void PublishSettingsDialog::show_inner_page(size_t section_index, int inner_inde save_scroll_position(m_categories[section.categories[section.selected_inner]]); m_categories[section.categories[section.selected_inner]].page->Hide(); } + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + section.selected_mixed = -1; + } section.selected_inner = inner_index; Category& category = m_categories[section.categories[inner_index]]; category.page->Show(); category.scroll->FitInside(); category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Unselect(); + section.page_host_sizer->Layout(); +} + +void PublishSettingsDialog::show_mixed_page(size_t section_index, int mixed_index) +{ + if (section_index >= m_sections.size()) + return; + SectionGroup& section = m_sections[section_index]; + if (mixed_index < 0 || mixed_index >= static_cast(section.mixed_categories.size())) + return; + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + } + if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { + save_scroll_position(m_categories[section.categories[section.selected_inner]]); + m_categories[section.categories[section.selected_inner]].page->Hide(); + section.selected_inner = -1; + } + section.selected_mixed = mixed_index; + Category& category = m_categories[section.mixed_categories[mixed_index]]; + category.page->Show(); + category.scroll->FitInside(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + section.tabs->Unselect(); section.page_host_sizer->Layout(); } @@ -724,12 +1431,25 @@ void PublishSettingsDialog::on_inner_tab_changed(size_t section_index, wxCommand show_inner_page(section_index, selection); } +void PublishSettingsDialog::on_mixed_tab_changed(size_t section_index, wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (section_index < m_sections.size() && selection >= 0 && + selection < static_cast(m_sections[section_index].mixed_categories.size())) + show_mixed_page(section_index, selection); +} + void PublishSettingsDialog::bind_tab_events() { m_outer_tabs->Bind(wxEVT_TAB_SEL_CHANGED, &PublishSettingsDialog::on_outer_tab_changed, this); - for (size_t section_index = 0; section_index < m_sections.size(); ++section_index) + for (size_t section_index = 0; section_index < m_sections.size(); ++section_index) { m_sections[section_index].tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { on_inner_tab_changed(section_index, event); }); + if (m_sections[section_index].mixed_tabs != nullptr) + m_sections[section_index].mixed_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { + on_mixed_tab_changed(section_index, event); + }); + } } void PublishSettingsDialog::apply_filter(const wxString& filter_text) @@ -781,7 +1501,9 @@ void PublishSettingsDialog::refresh_filter(const wxString& filter) has_match = has_match || m_rows[r].matches_filter; category.info->Show(!has_match); if (!has_match) - category.info->SetLabel(pseudo ? (want_checked ? m_info_nonsel : m_info_allsel) : m_info_empty); + // A mixed slot has no selectable rows: always explain it is published whole. + category.info->SetLabel(category.is_mixed ? m_info_mix : + (pseudo ? (want_checked ? m_info_nonsel : m_info_allsel) : m_info_empty)); if (has_match && first_inner < 0) { first_outer = s; first_inner = static_cast(inner); @@ -789,6 +1511,13 @@ void PublishSettingsDialog::refresh_filter(const wxString& filter) if (static_cast(s) == m_selected_outer && static_cast(inner) == m_sections[s].selected_inner) active_has_match = has_match; } + // Mixed slots have no rows: they can never match a filter, so they always fall back to + // their explanatory hint (shown once the slot is enabled). + for (size_t mixed : m_sections[s].mixed_categories) { + Category& category = m_categories[mixed]; + category.info->Show(true); + category.info->SetLabel(m_info_mix); + } } if (!active_has_match && first_inner >= 0 && (m_selected_outer != static_cast(first_outer) || m_sections[first_outer].selected_inner != first_inner)) { @@ -815,18 +1544,26 @@ void PublishSettingsDialog::apply_visibility() { Freeze(); for (Category& category : m_categories) { + // A disabled material slot hides everything below its header (rows, info and the Full + // Publish line); enable it first to reveal its settings. + const bool enabled = category.section != Section::Material || + (category.enable_check != nullptr && category.enable_check->GetValue()); + if (category.scroll != nullptr) + category.scroll->Show(enabled); + if (category.full_line_item != nullptr) + category.full_line_item->Show(enabled); bool category_any = false; for (size_t r : category.rows) category_any = category_any || m_rows[r].matches_filter; - category.info->Show(!category_any); + category.info->Show(enabled && !category_any); for (Subcategory& sub : category.subs) { bool sub_any = false; for (size_t r : sub.rows) sub_any = sub_any || m_rows[r].matches_filter; if (sub.header != nullptr) - sub.item->Show(sub_any); + sub.item->Show(enabled && sub_any); for (size_t r : sub.rows) - m_rows[r].item->Show(m_rows[r].matches_filter); + m_rows[r].item->Show(enabled && m_rows[r].matches_filter); } category.list_sizer->Layout(); category.scroll->FitInside(); @@ -840,6 +1577,17 @@ void PublishSettingsDialog::select_all(bool value) for (Row& row : m_rows) if (row.check->IsEnabled()) row.check->SetValue(value); + // "All" also enables every material slot (so its rows/Full Publish become visible and the + // selection is actually exported); "None" disables them all again. + for (Category& cat : m_categories) + if (cat.section == Section::Material && cat.enable_check != nullptr) + cat.enable_check->SetValue(value); + // wxCheckBox::SetValue does not emit wxEVT_CHECKBOX, so re-run the enable handlers to + // propagate mixed-slot components and refresh visibility as if the user had clicked. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].section == Section::Material) + on_enable_toggle(c); + apply_visibility(); } bool PublishSettingsDialog::row_is_visible(const Row& row) const @@ -936,28 +1684,13 @@ std::vector PublishSettingsDialog::GetPublishedKeys() const std::vector out; // Process and printer sections both travel through published_keys (the load-side overlay // applies process keys to the prints edited preset and the allowlisted printer keys to the - // printers edited preset); material keys use a separate API. - const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + // printers edited preset); material keys use a separate API. Printer rows carry the full + // per-extruder "#N" opt_id (built in Phase 1), so a checked row publishes exactly that + // extruder's value - per-extruder selection is independent. for (const Row& row : m_rows) { if ((row.section != Section::Print && row.section != Section::Printer) || !row.check->GetValue()) continue; - if (row.section == Section::Printer) { - // Printer rows store the base key (per-extruder "#N" variants collapsed during - // build). Publish every extruder element so the load side can apply per-extruder - // values even when the receiver has a different extruder count; a scalar printer - // key is published as-is. - const std::string base_key = publish_base_key(row.key); - if (const ConfigOption* opt = full.option(base_key)) { - if (const auto* vec = dynamic_cast(opt)) { - for (size_t i = 0; i < vec->size(); ++i) - out.push_back(base_key + "#" + std::to_string(i)); - } else { - out.push_back(base_key); - } - } - } else { - out.push_back(row.key); - } + out.push_back(row.key); } return out; } @@ -968,6 +1701,9 @@ std::vector PublishSettingsDialog::GetPublishedM for (const Category& cat : m_categories) { if (cat.section != Section::Material) continue; + // A slot that is not "Enable"d publishes nothing at all. + if (cat.enable_check != nullptr && !cat.enable_check->GetValue()) + continue; Slic3r::PublishedMaterialEntry entry; entry.filament_type = cat.filament_type; entry.filament_vendor = cat.filament_vendor; @@ -983,6 +1719,28 @@ std::vector PublishSettingsDialog::GetPublishedM entry.preset_name = preset->name; } } + // A mixed slot publishes as one unit: its definition (components, ratios, gradient) + // always travels (its Enable implies this), and the component filaments are enabled + + // Full Published by on_enable_toggle into their own entries. + if (cat.is_mixed) { + Slic3r::PublishedMaterialEntry mixed_entry; + mixed_entry.filament_type = cat.filament_type; + mixed_entry.filament_vendor = cat.filament_vendor; + mixed_entry.filament_id = cat.filament_id; + mixed_entry.slot = static_cast(cat.filament_slot); + // The mix's own colour (blended) is a property of the definition, not a + // requirement row; carry it so the receiver renders the swatch. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const std::string mix_color = filament_color_hex(full_cfg, cat.filament_slot); + if (!mix_color.empty()) { + mixed_entry.publish_color = true; + mixed_entry.color = mix_color; + } + for (const std::string& key : publish_mixed_keys()) + mixed_entry.keys.emplace_back(key); + out.push_back(std::move(mixed_entry)); + continue; + } // "Full Publish": the whole filament preset is embedded; type and colour are implicitly // published, and the per-key rows are disabled / their state ignored. if (cat.full_check != nullptr && cat.full_check->GetValue()) { @@ -1050,11 +1808,16 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) for (Category& cat : m_categories) { if (cat.full_check != nullptr) cat.full_check->Refresh(); + if (cat.enable_check != nullptr) + cat.enable_check->Refresh(); if (cat.title_label != nullptr) cat.title_label->Refresh(); if (cat.filament_color_chip != nullptr) { - if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, cat.filament_slot), "", FromDIP(12), FromDIP(12))) + // Mixed pages have no header chip; this only ever fires for physical slots. + if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, cat.filament_slot), std::to_string(cat.filament_slot + 1), + FromDIP(20), FromDIP(20))) { cat.filament_color_chip->SetBitmap(*chip); + } } cat.scroll->FitInside(); cat.list_sizer->Layout(); @@ -1066,12 +1829,17 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) if (section.icon_bmp.bmp().IsOk()) m_outer_tabs->SetItemBitmap(s, section.icon_bmp.bmp()); section.tabs->Rescale(); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Rescale(); } - // Refresh the per-row Color chips at the new DPI. + // Refresh the per-row Color chips at the new DPI (they carry the slot number too). for (Row& row : m_rows) { if (row.color_chip != nullptr && !row.value.IsEmpty()) { - if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), "", FromDIP(12), FromDIP(12))) + const std::string chip_label = (row.inner_index < m_categories.size()) ? + std::to_string(m_categories[row.inner_index].filament_slot + 1) : + ""; + if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) row.color_chip->SetBitmap(*chip); } } @@ -1080,9 +1848,19 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) const Category& category = m_categories[category_index]; if (category.section != Section::Material) continue; - if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, category.filament_slot), "", FromDIP(12), FromDIP(12))) { - const SectionGroup& section = m_sections[category.group]; - const auto iter = std::find(section.categories.begin(), section.categories.end(), category_index); + const SectionGroup& section = m_sections[category.group]; + if (category.is_mixed) { + // The tab carries the full composition bitmap (mix chip + components + percents); + // the page header has no chip/title to refresh. + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full, category.filament_slot, FromDIP(20)); + if (tab_bmp.IsOk()) { + const auto iter = std::find(section.mixed_categories.begin(), section.mixed_categories.end(), category_index); + if (iter != section.mixed_categories.end() && section.mixed_tabs != nullptr) + section.mixed_tabs->SetItemBitmap(static_cast(iter - section.mixed_categories.begin()), tab_bmp); + } + } else if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, category.filament_slot), + std::to_string(category.filament_slot + 1), FromDIP(20), FromDIP(20))) { + const auto iter = std::find(section.categories.begin(), section.categories.end(), category_index); if (iter != section.categories.end()) m_sections[category.group].tabs->SetItemBitmap(static_cast(iter - section.categories.begin()), *chip); } diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp index 2cf49e7eb9..e00bec37fd 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.hpp +++ b/src/slic3r/GUI/PublishSettingsDialog.hpp @@ -7,8 +7,10 @@ #include "libslic3r/PublishSettings.hpp" #include +#include #include #include +#include #include #include @@ -105,9 +107,18 @@ private: wxPoint scroll_pos{0, 0}; wxStaticBitmap* filament_color_chip{nullptr}; wxStaticText* title_label{nullptr}; // material title (static text; Full Publish carries the label elsewhere) + // "Enable": while unchecked nothing of this slot is exported and everything below the + // header row is hidden. For physical slots the Full Publish toggle sits on a second + // line (full_line_item) visible only when enabled; for mixed slots Enable alone implies + // publishing the mix definition, so no Full Publish widget exists at all. + wxCheckBox* enable_check{nullptr}; + wxSizerItem* full_line_item{nullptr}; // sizer item of the Full Publish line (physical slots only) // "Full Publish": while checked, the whole slot preset is serialized and its rows // (incl. Color/Type) are disabled. wxCheckBox* full_check{nullptr}; + // True for a mixed-color filament slot: no Material/Retraction rows; Enable publishes + // the slot's gradient/ratio definition as a whole. + bool is_mixed{false}; // Material identity, only for Section::Material categories. std::string filament_type; std::string filament_vendor; @@ -118,6 +129,21 @@ private: std::vector rows; // flattened rows of this category }; + // Frozen snapshot of a mixed filament slot's definition for the read-only visualization + // painted on the slot's page. Plain data only: the paint handler must never touch the + // config. For gradient slots the curve is pre-sampled (t, ratio) pairs, where ratio is the + // first component's share over model height; anchors carry the raw control points. + struct MixedVisualSpec + { + bool valid{false}; + bool is_gradient{false}; + std::vector component_colours; // colour per component, in config order + std::vector ratios; // sublayer shares summing to ~1 (non-gradient) + std::vector tri_weights; // 3-component mixes: barycentric shares + std::vector> gradient_samples; + std::vector> gradient_anchors; + }; + // One outer TabCtrl page. Category entries are its inner tabs. struct SectionGroup { @@ -127,13 +153,24 @@ private: ScalableBitmap icon_bmp; // tab icon next to the title; rescaled on DPI change wxPanel* page{nullptr}; TabCtrl* tabs{nullptr}; + // Second tab strip, below the main one, listing only the mixed-color filament slots. + // Present on the Material section only (null elsewhere). + TabCtrl* mixed_tabs{nullptr}; wxPanel* page_host{nullptr}; wxBoxSizer* page_host_sizer{nullptr}; int selected_inner{-1}; - std::vector categories; // indices into m_categories + // Selected mixed tab (index into mixed_categories), valid while a mixed slot page is shown. + int selected_mixed{-1}; + std::vector categories; // indices into m_categories (physical slots) + std::vector mixed_categories; // indices into m_categories (mixed slots) }; void build_option_model(); + // Frozen snapshot of a mixed slot's definition for the page visualization, resolved from + // the full config once at dialog-build time. Gradient slots pre-sample exactly what the + // slicer will print: the custom curve wins over the gradient_range endpoints over the + // 0.10 -> 0.90 default (the resolution FilamentBitmapUtils::mixed_gradient_curve mirrors). + static MixedVisualSpec make_mixed_visual_spec(const Slic3r::DynamicPrintConfig& full, size_t slot); void apply_filter(const wxString& filter_text); // Menu-only pseudo filters: show only the checked ("Filter selected") or only the // unchecked ("Filter non-selected") rows. The search box keeps the user's text. @@ -147,13 +184,21 @@ private: void set_row_bold(Row& row, bool bold); // "Full Publish" toggled: disables/enables the material's rows. void on_full_toggle(size_t category_index); + // "Enable" toggled on a material slot: reveals/hides everything below the header and, for a + // mixed slot, auto-selects its component filaments' "Enable" + "Full Publish" toggles. + void on_enable_toggle(size_t category_index); + // Read-only visualization of a mixed slot's definition (a stacked ratio bar, or the + // Material Ratio vs Model Height graph for a gradient), inserted above the info hint + // inside the category's scroll area. + void add_mixed_visual(size_t category_index, const MixedVisualSpec& spec); // Return/create the fixed outer page for a Section kind. size_t section_group_for(Section kind); size_t category_index_for(const wxString& title, Section section, size_t group, size_t source_index, - const PublishMaterialIdentity& identity = PublishMaterialIdentity()); + const PublishMaterialIdentity& identity = PublishMaterialIdentity(), + bool is_mixed = false); size_t subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon); void add_row_ui(const std::string& key, const wxString& label, @@ -167,8 +212,10 @@ private: void save_scroll_position(Category& category); void show_outer_page(size_t section_index); void show_inner_page(size_t section_index, int inner_index); + void show_mixed_page(size_t section_index, int mixed_index); void on_outer_tab_changed(wxCommandEvent& event); void on_inner_tab_changed(size_t section_index, wxCommandEvent& event); + void on_mixed_tab_changed(size_t section_index, wxCommandEvent& event); bool row_is_visible(const Row& row) const; void apply_visibility(); void bind_tab_events(); @@ -190,6 +237,7 @@ private: wxString m_info_nonsel; wxString m_info_allsel; wxString m_info_empty; + wxString m_info_mix; // body hint shown for a mixed slot (published as a whole) ScalableBitmap m_search; ScalableBitmap m_menu; diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 7817c9111a..8fc432771e 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -2,8 +2,8 @@ #include -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); BEGIN_EVENT_TABLE(TabCtrl, StaticBox) @@ -22,11 +22,7 @@ END_EVENT_TABLE() #define TAB_BUTTON_PADDING_Y 2 #define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y -TabCtrl::TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos, - const wxSize & size, - long style) +TabCtrl::TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style) : StaticBox(parent, id, pos, size, style) { #if 0 @@ -42,14 +38,11 @@ TabCtrl::TabCtrl(wxWindow * parent, hsizer->Add(sizer, 0, wxEXPAND | wxBOTTOM, border_width * 4); SetSizer(hsizer); Bind(wxEVT_COMMAND_BUTTON_CLICKED, &TabCtrl::buttonClicked, this); - //wxString reason; - //IsTransparentBackgroundSupported(&reason); + // wxString reason; + // IsTransparentBackgroundSupported(&reason); } -TabCtrl::~TabCtrl() -{ - delete images; -} +TabCtrl::~TabCtrl() { delete images; } int TabCtrl::GetSelection() const { return sel; } @@ -75,14 +68,11 @@ void TabCtrl::SelectItem(int item) Refresh(); } -void TabCtrl::Unselect() -{ - SelectItem(-1); -} +void TabCtrl::Unselect() { SelectItem(-1); } void TabCtrl::Rescale() { - for (auto & b : btns) + for (auto& b : btns) b->Rescale(); relayout(); } @@ -96,23 +86,20 @@ bool TabCtrl::SetFont(wxFont const& font) return true; } -int TabCtrl::AppendItem(const wxString &item, - int image, int selImage, - void * clientData) +int TabCtrl::AppendItem(const wxString& item, int image, int selImage, void* clientData) { - Button * btn = new Button(); + Button* btn = new Button(); btn->Create(this, item, "", wxBORDER_NONE); btn->SetFont(GetFont()); - btn->SetTextColor(StateColor( - std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), - std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal))); + btn->SetTextColor( + StateColor(std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal))); btn->SetBackgroundColor(StateColor()); btn->SetCornerRadius(0); btn->SetPaddingSize({TAB_BUTTON_PADDING}); btns.push_back(btn); if (btns.size() > 1) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); - sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE * 2); + sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, item_space * 2); sizer->AddStretchSpacer(1); relayout(); return btns.size() - 1; @@ -144,7 +131,7 @@ bool TabCtrl::DeleteItem(int item) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); if (selection_changed) { - sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` + sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` } relayout(); if (selection_changed) { @@ -167,14 +154,12 @@ void TabCtrl::DeleteAllItems() unsigned int TabCtrl::GetCount() const { return btns.size(); } -wxString TabCtrl::GetItemText(unsigned int item) const -{ - return item < btns.size() ? btns[item]->GetLabel() : wxString{}; -} +wxString TabCtrl::GetItemText(unsigned int item) const { return item < btns.size() ? btns[item]->GetLabel() : wxString{}; } -void TabCtrl::SetItemText(unsigned int item, wxString const &value) +void TabCtrl::SetItemText(unsigned int item, wxString const& value) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetLabel(value); } @@ -188,61 +173,59 @@ void TabCtrl::SetItemBitmap(unsigned int item, const wxBitmap& bitmap) bool TabCtrl::GetItemBold(unsigned int item) const { - if (item >= btns.size()) return false; + if (item >= btns.size()) + return false; return btns[item]->GetFont() == bold; } void TabCtrl::SetItemBold(unsigned int item, bool bold) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetFont(bold ? this->bold : GetFont()); btns[item]->Rescale(); } void* TabCtrl::GetItemData(unsigned int item) const { - if (item >= btns.size()) return nullptr; + if (item >= btns.size()) + return nullptr; return btns[item]->GetClientData(); } void TabCtrl::SetItemData(unsigned int item, void* clientData) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetClientData(clientData); } void TabCtrl::AssignImageList(wxImageList* imageList) { - if (images == imageList) return; + if (images == imageList) + return; delete images; images = imageList; } -void TabCtrl::SetItemTextColour(unsigned int item, const StateColor &col) +void TabCtrl::SetItemTextColour(unsigned int item, const StateColor& col) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetTextColor(col); } -int TabCtrl::GetFirstVisibleItem() const -{ - return btns.size() == 0 ? -1 : 0; -} +int TabCtrl::GetFirstVisibleItem() const { return btns.size() == 0 ? -1 : 0; } -int TabCtrl::GetNextVisible(int item) const -{ - return ++item < btns.size() ? item : -1; -} +int TabCtrl::GetNextVisible(int item) const { return ++item < btns.size() ? item : -1; } -bool TabCtrl::IsVisible(unsigned int item) const -{ - return true; -} +bool TabCtrl::IsVisible(unsigned int item) const { return true; } void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) { wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; + if (sizeFlags & wxSIZE_USE_EXISTING) + return; relayout(); } @@ -250,7 +233,9 @@ void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) { - if (nMsg == WM_GETDLGCODE) { return DLGC_WANTARROWS; } + if (nMsg == WM_GETDLGCODE) { + return DLGC_WANTARROWS; + } return wxWindow::MSWWindowProc(nMsg, wParam, lParam); } @@ -259,15 +244,15 @@ WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) void TabCtrl::relayout() { int offset = 10; - int item = sel + 1; - int first = 0; + int item = sel + 1; + int first = 0; for (int i = 0; i < item; ++i) - offset += btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + offset += btns[i]->GetMinSize().x + item_space * 2; if (item < btns.size()) - offset += btns[item]->GetMinSize().x + TAB_BUTTON_SPACE * 2; - int width = GetSize().x; + offset += btns[item]->GetMinSize().x + item_space * 2; + int width = GetSize().x; for (int i = 0; i < btns.size(); ++i) { - auto size = btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + auto size = btns[i]->GetMinSize().x + item_space * 2; if (i < sel && offset > width) { sizer->Show(i * 2 + 1, false); sizer->Show(i * 2 + 2, false); @@ -288,23 +273,32 @@ void TabCtrl::relayout() sizer->GetItem(i * 2 + 2)->SetMinSize({0, 0}); } if (item >= btns.size()) - -- item; + --item; // Keep spacing 2 ~ 10 TAB_BUTTON_SPACE - int b = GetSize().x - offset - 10 - (item + 1 - first) * TAB_BUTTON_SPACE * 8; + int b = GetSize().x - offset - 10 - (item + 1 - first) * item_space * 8; sizer->GetItem(item * 2 + 2)->SetMinSize({b > 0 ? b : 0, 0}); Layout(); } -int TabCtrl::buttons_best_width() const +void TabCtrl::SetItemSpace(int space) +{ + if (space < 0 || space == item_space) + return; + item_space = space; + relayout(); + Refresh(); +} + +int TabCtrl::GetFullSize() const { // Mirrors relayout(): a 10px leading spacer plus every button's min width and spacing. int width = 10; - for (const Button *btn : btns) - width += btn->GetMinSize().x + TAB_BUTTON_SPACE * 2; + for (const Button* btn : btns) + width += btn->GetMinSize().x + item_space * 2; return width; } -void TabCtrl::buttonClicked(wxCommandEvent &event) +void TabCtrl::buttonClicked(wxCommandEvent& event) { SetFocus(); auto btn = event.GetEventObject(); @@ -312,7 +306,7 @@ void TabCtrl::buttonClicked(wxCommandEvent &event) SelectItem(iter == btns.end() ? -1 : iter - btns.begin()); } -void TabCtrl::keyDown(wxKeyEvent &event) +void TabCtrl::keyDown(wxKeyEvent& event) { switch (event.GetKeyCode()) { case WXK_UP: @@ -331,11 +325,13 @@ void TabCtrl::keyDown(wxKeyEvent &event) void TabCtrl::doRender(wxDC& dc) { wxSize size = GetSize(); - int states = state_handler.states(); - if (sel < 0) { return; } + int states = state_handler.states(); + if (sel < 0) { + return; + } - auto x1 = btns[sel]->GetPosition().x; - auto x2 = x1 + btns[sel]->GetSize().x; + auto x1 = btns[sel]->GetPosition().x; + auto x2 = x1 + btns[sel]->GetSize().x; const int BS2 = (1 + border_width) / 2; #if 0 const int BS = border_width / 2; diff --git a/src/slic3r/GUI/Widgets/TabCtrl.hpp b/src/slic3r/GUI/Widgets/TabCtrl.hpp index e6a990243e..0d3606aca7 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.hpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.hpp @@ -3,33 +3,30 @@ #include "Button.hpp" -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); class TabCtrl : public StaticBox { std::vector btns; wxImageList* images = nullptr; - wxBoxSizer * sizer = nullptr; + wxBoxSizer* sizer = nullptr; int sel = -1; wxFont bold; + int item_space = 2; // space around each button, both sides (SetItemSpace) public: - TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos = wxDefaultPosition, - const wxSize & size = wxDefaultSize, - long style = 0); + TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = 0); ~TabCtrl(); public: - virtual bool SetFont(wxFont const & font) override; + virtual bool SetFont(wxFont const& font) override; public: - int AppendItem(const wxString &item, int image = -1, int selImage = -1, void *clientData = nullptr); - int AppendItem(const wxString &item, const wxBitmap& bitmap, void *clientData = nullptr); + int AppendItem(const wxString& item, int image = -1, int selImage = -1, void* clientData = nullptr); + int AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData = nullptr); bool DeleteItem(int item); @@ -37,7 +34,7 @@ public: unsigned int GetCount() const; - int GetSelection() const; + int GetSelection() const; void SelectItem(int item); @@ -46,16 +43,16 @@ public: virtual void Rescale(); wxString GetItemText(unsigned int item) const; - void SetItemText(unsigned int item, wxString const &value); - void SetItemBitmap(unsigned int item, const wxBitmap& bitmap); + void SetItemText(unsigned int item, wxString const& value); + void SetItemBitmap(unsigned int item, const wxBitmap& bitmap); - bool GetItemBold(unsigned int item) const; - void SetItemBold(unsigned int item, bool bold); + bool GetItemBold(unsigned int item) const; + void SetItemBold(unsigned int item, bool bold); - void* GetItemData(unsigned int item) const; - void SetItemData(unsigned int item, void *clientData); - - void AssignImageList(wxImageList *imageList); + void* GetItemData(unsigned int item) const; + void SetItemData(unsigned int item, void* clientData); + + void AssignImageList(wxImageList* imageList); void SetItemTextColour(unsigned int item, const StateColor& col); @@ -64,8 +61,11 @@ public: int GetNextVisible(int item) const; bool IsVisible(unsigned int item) const; - // Width of the tab strip that keeps every button visible (used to size the Publish dialog). - int buttons_best_width() const; + // Extra space around each tab button (in px on both sides). Defaults to the control-wide + // standard; call before appending items so every button picks it up. + void SetItemSpace(int space); + + int GetFullSize() const; private: virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; @@ -76,10 +76,10 @@ private: void relayout(); - void buttonClicked(wxCommandEvent & event); - void keyDown(wxKeyEvent &event); + void buttonClicked(wxCommandEvent& event); + void keyDown(wxKeyEvent& event); - void doRender(wxDC & dc) override; + void doRender(wxDC& dc) override; // some useful events bool sendTabCtrlEvent(bool changing = false); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index a459bfa62d..37147b93b8 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1024,3 +1024,55 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") { } } } + +// A published mixed filament serializes its whole definition (components, ratios, gradient) +// masked to the author's slot: the mix slot's values survive, the non-published slots reset to +// their defaults, so a partial publish never leaks another slot's mix data. +SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]") { + GIVEN("a full print configuration with three slots, one of them mixed") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; + full_cfg.opt("filament_is_mixed")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_components")->values = { "", "", "1,2" }; + full_cfg.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + full_cfg.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + full_cfg.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + full_cfg.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + + PublishedMaterialEntry mix_entry; + mix_entry.slot = 2; + mix_entry.keys = { + "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + + WHEN("filtering with a mixed entry for slot 2") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { mix_entry }); + + THEN("the author's mixed slot keeps its definition") { + REQUIRE(filtered_cfg.option("filament_is_mixed") != nullptr); + REQUIRE(filtered_cfg.opt("filament_is_mixed")->values == std::vector{ 0, 0, 1 }); + const auto& components = filtered_cfg.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(filtered_cfg.opt("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4"); + CHECK(filtered_cfg.opt("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9"); + CHECK(filtered_cfg.opt("filament_mixed_gradient")->values[2]); + CHECK(filtered_cfg.opt("filament_mixed_gradient_per_part")->values[2]); + } + THEN("the non-published slots are masked to their defaults") { + CHECK(filtered_cfg.opt("filament_mixed_components")->values[0] == ""); + CHECK(filtered_cfg.opt("filament_mixed_components")->values[1] == ""); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[0] == 0); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[1] == 0); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 01abba75bf..d4a1e04e0c 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -2545,3 +2545,252 @@ TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count CHECK(pub.skipped_keys.empty()); } } + +// A published mixed filament serializes its definition (components, ratios, gradient) into the +// receiver's project_config - the project-level parallel arrays, not a filament preset. The +// mix's own blended colour is carried as publish_color so the receiver renders the swatch. +TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2) + // blending slots 1 and 2 at 60/40 with a gradient. + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99", "GFL99" }; + // The mixed slot's definition. These keys are project-level arrays in the full config; + // on export they are masked so only the published slot's entry survives. + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + return config; + }; + + // A receiver that already carries the mix slot at index 2 (e.g. a two-physical-plus-one-mix + // project with the same layout). + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PETG", "My PLA" }; + + // Grow the receiver's project arrays to 3 slots first, as set_num_filaments would. + bundle.set_num_filaments(3); + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition landed in project_config's parallel arrays at the author slot. + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 3); + CHECK(ratios[2] == "0.6,0.4"); + const auto &gradient = bundle.project_config.opt("filament_mixed_gradient")->values; + CHECK(gradient[2]); + const auto &range = bundle.project_config.opt("filament_mixed_gradient_range")->values; + CHECK(range[2] == "0.9,0.1"); + const auto &curve = bundle.project_config.opt("filament_mixed_gradient_curve")->values; + CHECK(curve[2] == "0,0.1|1,0.9"); + const auto &per_part = bundle.project_config.opt("filament_mixed_gradient_per_part")->values; + CHECK(per_part[2]); + // The mix's blended colour crossed into project_config for the swatch. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 3); + CHECK(colour[2] == "#800080"); + // The other slots were not overwritten by the mask. + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + // Nothing skipped: every serialized mixed key was applied. + CHECK(pub.skipped_keys.empty()); + } + + // A receiver with fewer slots: the slot is grown and seeded before the definition applies. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry mix; + mix.slot = 2; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(pub.skipped_keys.empty()); + } +} + +// A published mixed filament whose definition cannot be applied is reported as skipped instead +// of aborting the load: the entry lists a mixed key that the file's payload does not carry. +TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + // Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the + // entry lists them, the file config does not. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + + PublishedMaterialEntry mix; + mix.slot = 1; + mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows to two slots; the missing payload keys are reported as skipped rather + // than dropped silently (material_label is empty for this entry). + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)")); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)")); +} + +// A single-extruder receiver collapses the author's per-extruder printer slots onto its single +// slot: the first serialized variant of a base key is applied, the remaining variants of that +// base key are reported as skipped. +TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + config.opt("retraction_speed")->values = { 30.0, 40.0 }; + Preset::normalize(config); + return config; + }; + + // Both extruders published: the first serialized variant (#0, left) lands on the receiver's + // single slot; the second variant (#1) is reported as skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 25.0 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 30.0 }); + CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); + CHECK(contains_key(pub.skipped_keys, "retraction_speed#1")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0")); + } + + // Only the second extruder published: the single-extruder receiver still applies it (the + // author's "right" is the only serialized slot) and reports nothing skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.9 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise +// (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are +// reported as skipped. +TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + Preset::normalize(config); + return config; + }; + + // Receiver with two extruders: both published slots override element-wise. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9 }); + CHECK(pub.skipped_keys.empty()); + } + + // Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.7 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9, 0.7 }); + CHECK(pub.skipped_keys.empty()); + } +} +