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
This commit is contained in:
Lam Wei Lun
2026-08-27 13:17:19 +08:00
parent ce277ebbf5
commit 76d9b8bac0
12 changed files with 1474 additions and 236 deletions

View File

@@ -5291,6 +5291,11 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
const std::set<std::string> printer_option_set(printer_options.begin(), printer_options.end()); const std::set<std::string> printer_option_set(printer_options.begin(), printer_options.end());
std::set<std::string> contract_excluded_keys; std::set<std::string> contract_excluded_keys;
auto apply_published = [&](DynamicPrintConfig& target, const std::set<std::string>* allowlist) { auto apply_published = [&](DynamicPrintConfig& target, const std::set<std::string>* 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<std::string> collapsed_bases;
for (const std::string& key : published_config->published_keys) { for (const std::string& key : published_config->published_keys) {
if (applied_keys.count(key) != 0) if (applied_keys.count(key) != 0)
continue; // already applied continue; // already applied
@@ -5309,7 +5314,7 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
if (src_opt == nullptr) if (src_opt == nullptr)
continue; // key not present in the loaded config; record later continue; // key not present in the loaded config; record later
if (src_opt->is_vector()) { 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()) if (dst_opt == nullptr || !dst_opt->is_vector())
continue; // cannot apply; will be reported as skipped continue; // cannot apply; will be reported as skipped
// Type mismatch: ConfigOptionVector::set() throws ConfigurationError on a // 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()) if (dst_opt->type() != src_opt->type())
continue; continue;
// A '#N' variant key (e.g. per-extruder retraction_length#2) applies one // 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 // 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). // indices are skipped (set_at would otherwise resize the receiver's vector).
if (key.size() > base_key.size()) { if (key.size() > base_key.size()) {
@@ -5341,16 +5346,33 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
break; break;
} }
} }
if (!valid || idx >= static_cast<const ConfigOptionVectorBase*>(src_opt)->size() || const size_t src_size = static_cast<const ConfigOptionVectorBase*>(src_opt)->size();
idx >= static_cast<const ConfigOptionVectorBase*>(dst_opt)->size()) if (!valid || idx >= src_size)
continue; // malformed or out-of-range variant: cannot apply; reported as skipped continue; // malformed or out-of-range on the author's side: cannot apply; reported as skipped
const size_t dst_size = static_cast<const ConfigOptionVectorBase*>(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<ConfigOptionVectorBase*>(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<const ConfigOptionVectorBase*>(src_opt)->size() != } else if (static_cast<const ConfigOptionVectorBase*>(src_opt)->size() !=
static_cast<const ConfigOptionVectorBase*>(dst_opt)->size()) { static_cast<const ConfigOptionVectorBase*>(dst_opt)->size()) {
// Whole-vector base key: the receiver must have a matching vector size, // Whole-vector base key: the receiver must have a matching vector size,
// otherwise applying would overwrite a different number of elements. // otherwise applying would overwrite a different number of elements.
continue; // cannot apply; will be reported as skipped 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); applied_keys.insert(key);
} else { } else {
// A scalar key cannot carry a '#N' suffix; a hand-crafted file listing one // 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); proj_nozzle_map->values.resize(target_slots, 0);
if (proj_volume_map && proj_volume_map->values.size() < target_slots) if (proj_volume_map && proj_volume_map->values.size() < target_slots)
proj_volume_map->values.resize(target_slots, static_cast<int>(NozzleVolumeType::nvtStandard)); proj_volume_map->values.resize(target_slots, static_cast<int>(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<ConfigOptionBools>("filament_is_mixed"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, false);
if (auto* opt = this->project_config.opt<ConfigOptionStrings>("filament_mixed_components"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, std::string{});
if (auto* opt = this->project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, std::string{});
if (auto* opt = this->project_config.opt<ConfigOptionBools>("filament_mixed_gradient"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, false);
if (auto* opt = this->project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_range"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, std::string{});
if (auto* opt = this->project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve"))
if (opt->values.size() < target_slots)
opt->values.resize(target_slots, std::string{});
if (auto* opt = this->project_config.opt<ConfigOptionBools>("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) if (this->ams_multi_color_filment.size() < target_slots)
this->ams_multi_color_filment.resize(target_slots); this->ams_multi_color_filment.resize(target_slots);
for (size_t slot = old_colour_count; slot < target_slots; ++slot) { 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 // Colour is slot-scoped and independent of the type gate; it is also synced
// into project_config for GUI rendering. // into project_config for GUI rendering.
if (entry.publish_color && !entry.color.empty()) { 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 // Create the key when the target preset lacks it: the colour is a
// requirement, not an override. // requirement, not an override.
if (ConfigOptionStrings* colour = write_config.opt<ConfigOptionStrings>("filament_colour", true)) { if (ConfigOptionStrings* colour = write_config.opt<ConfigOptionStrings>("filament_colour", true)) {
@@ -6016,8 +6068,36 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
} }
} }
if (apply_slot && recv != nullptr) if (apply_slot && recv != nullptr) {
apply_slot_keys(write_config, entry.keys, entry.slot, material_label); // 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<std::string>& mixed_keys = publish_mixed_keys();
std::vector<std::string> 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<int>(static_cast<const ConfigOptionVectorBase*>(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<const ConfigOptionVectorBase*>(dst_opt)->size()) {
static_cast<ConfigOptionVectorBase*>(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);
}
} }
} }
} }

View File

@@ -83,6 +83,23 @@ const std::set<std::string>& publish_structural_keys()
return structural_keys; return structural_keys;
} }
const std::set<std::string>& 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<std::string> 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. // 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 // 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). // lists, so any key shown there must be publishable here (and vice versa).

View File

@@ -15,6 +15,13 @@ std::string publish_base_key(const std::string &key);
// filter_published_config because 3MF validation needs it - exported, never applied. // filter_published_config because 3MF validation needs it - exported, never applied.
const std::set<std::string>& publish_structural_keys(); const std::set<std::string>& 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<std::string>& publish_mixed_keys();
// One row of the printer tab's "Retraction" / "Z-Hop" optgroups (key + tab icon id), kept // 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. // together so the tab can later be migrated onto these lists.
struct PublishablePrinterOption { struct PublishablePrinterOption {

View File

@@ -11,6 +11,45 @@
namespace Slic3r { namespace GUI { 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) void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, const wxColour& to)
{ {
if (rect.width <= 0 || rect.height <= 0) return; if (rect.width <= 0 || rect.height <= 0) return;
@@ -73,7 +112,7 @@ std::vector<wxColour> sample_gradient_ramp(const wxColour& first,
// Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in // 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 // ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's
// endpoints, otherwise the 0.10 -> 0.90 default. // 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<ConfigOptionStrings>("filament_mixed_gradient_curve"); const auto* curve_opt = cfg.option<ConfigOptionStrings>("filament_mixed_gradient_curve");
if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) {

View File

@@ -13,6 +13,16 @@ namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; }
namespace Slic3r { namespace GUI { 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. // Fills a rect with a west->east linear gradient by drawing solid 1px columns.
// Use instead of wxDC::GradientFillLinear, whose CoreGraphics (CGShading) backend // Use instead of wxDC::GradientFillLinear, whose CoreGraphics (CGShading) backend
// fails to render on some macOS builds; solid fills are unaffected. // fails to render on some macOS builds; solid fills are unaffected.
@@ -51,6 +61,12 @@ std::vector<wxColour> sample_gradient_ramp(const wxColour& first,
// destination's height in pixels. // destination's height in pixels.
std::vector<wxColour> mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); std::vector<wxColour> 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. // Fill rect with a ramp, ramp.front() along the bottom edge.
void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector<wxColour>& ramp); void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector<wxColour>& ramp);

View File

@@ -919,52 +919,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider()
} }
// ---- Triangle (ternary) ratio picker ---- // ---- Triangle (ternary) ratio picker ----
// The barycentric utilities (TriPoint, tri_contains, tri_barycentric, tri_clamp) live in
// Barycentric coordinate utilities // FilamentBitmapUtils so the Publish dialog can mirror this picker read-only.
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};
}
wxBoxSizer* MixedFilamentDialog::create_triangle_picker() wxBoxSizer* MixedFilamentDialog::create_triangle_picker()
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,10 @@
#include "libslic3r/PublishSettings.hpp" #include "libslic3r/PublishSettings.hpp"
#include <wx/wx.h> #include <wx/wx.h>
#include <wx/colour.h>
#include <wx/scrolwin.h> #include <wx/scrolwin.h>
#include <wx/menu.h> #include <wx/menu.h>
#include <utility>
#include <vector> #include <vector>
#include <string> #include <string>
@@ -105,9 +107,18 @@ private:
wxPoint scroll_pos{0, 0}; wxPoint scroll_pos{0, 0};
wxStaticBitmap* filament_color_chip{nullptr}; wxStaticBitmap* filament_color_chip{nullptr};
wxStaticText* title_label{nullptr}; // material title (static text; Full Publish carries the label elsewhere) 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 // "Full Publish": while checked, the whole slot preset is serialized and its rows
// (incl. Color/Type) are disabled. // (incl. Color/Type) are disabled.
wxCheckBox* full_check{nullptr}; 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. // Material identity, only for Section::Material categories.
std::string filament_type; std::string filament_type;
std::string filament_vendor; std::string filament_vendor;
@@ -118,6 +129,21 @@ private:
std::vector<size_t> rows; // flattened rows of this category std::vector<size_t> 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<wxColour> component_colours; // colour per component, in config order
std::vector<double> ratios; // sublayer shares summing to ~1 (non-gradient)
std::vector<double> tri_weights; // 3-component mixes: barycentric shares
std::vector<std::pair<double, double>> gradient_samples;
std::vector<std::pair<double, double>> gradient_anchors;
};
// One outer TabCtrl page. Category entries are its inner tabs. // One outer TabCtrl page. Category entries are its inner tabs.
struct SectionGroup struct SectionGroup
{ {
@@ -127,13 +153,24 @@ private:
ScalableBitmap icon_bmp; // tab icon next to the title; rescaled on DPI change ScalableBitmap icon_bmp; // tab icon next to the title; rescaled on DPI change
wxPanel* page{nullptr}; wxPanel* page{nullptr};
TabCtrl* tabs{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}; wxPanel* page_host{nullptr};
wxBoxSizer* page_host_sizer{nullptr}; wxBoxSizer* page_host_sizer{nullptr};
int selected_inner{-1}; int selected_inner{-1};
std::vector<size_t> 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<size_t> categories; // indices into m_categories (physical slots)
std::vector<size_t> mixed_categories; // indices into m_categories (mixed slots)
}; };
void build_option_model(); 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); void apply_filter(const wxString& filter_text);
// Menu-only pseudo filters: show only the checked ("Filter selected") or only the // 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. // 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); void set_row_bold(Row& row, bool bold);
// "Full Publish" toggled: disables/enables the material's rows. // "Full Publish" toggled: disables/enables the material's rows.
void on_full_toggle(size_t category_index); 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. // Return/create the fixed outer page for a Section kind.
size_t section_group_for(Section kind); size_t section_group_for(Section kind);
size_t category_index_for(const wxString& title, size_t category_index_for(const wxString& title,
Section section, Section section,
size_t group, size_t group,
size_t source_index, 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); size_t subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon);
void add_row_ui(const std::string& key, void add_row_ui(const std::string& key,
const wxString& label, const wxString& label,
@@ -167,8 +212,10 @@ private:
void save_scroll_position(Category& category); void save_scroll_position(Category& category);
void show_outer_page(size_t section_index); void show_outer_page(size_t section_index);
void show_inner_page(size_t section_index, int inner_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_outer_tab_changed(wxCommandEvent& event);
void on_inner_tab_changed(size_t section_index, 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; bool row_is_visible(const Row& row) const;
void apply_visibility(); void apply_visibility();
void bind_tab_events(); void bind_tab_events();
@@ -190,6 +237,7 @@ private:
wxString m_info_nonsel; wxString m_info_nonsel;
wxString m_info_allsel; wxString m_info_allsel;
wxString m_info_empty; wxString m_info_empty;
wxString m_info_mix; // body hint shown for a mixed slot (published as a whole)
ScalableBitmap m_search; ScalableBitmap m_search;
ScalableBitmap m_menu; ScalableBitmap m_menu;

View File

@@ -2,8 +2,8 @@
#include <wx/dc.h> #include <wx/dc.h>
wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent);
wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent);
BEGIN_EVENT_TABLE(TabCtrl, StaticBox) BEGIN_EVENT_TABLE(TabCtrl, StaticBox)
@@ -22,11 +22,7 @@ END_EVENT_TABLE()
#define TAB_BUTTON_PADDING_Y 2 #define TAB_BUTTON_PADDING_Y 2
#define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y #define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y
TabCtrl::TabCtrl(wxWindow * parent, TabCtrl::TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style)
wxWindowID id,
const wxPoint & pos,
const wxSize & size,
long style)
: StaticBox(parent, id, pos, size, style) : StaticBox(parent, id, pos, size, style)
{ {
#if 0 #if 0
@@ -42,14 +38,11 @@ TabCtrl::TabCtrl(wxWindow * parent,
hsizer->Add(sizer, 0, wxEXPAND | wxBOTTOM, border_width * 4); hsizer->Add(sizer, 0, wxEXPAND | wxBOTTOM, border_width * 4);
SetSizer(hsizer); SetSizer(hsizer);
Bind(wxEVT_COMMAND_BUTTON_CLICKED, &TabCtrl::buttonClicked, this); Bind(wxEVT_COMMAND_BUTTON_CLICKED, &TabCtrl::buttonClicked, this);
//wxString reason; // wxString reason;
//IsTransparentBackgroundSupported(&reason); // IsTransparentBackgroundSupported(&reason);
} }
TabCtrl::~TabCtrl() TabCtrl::~TabCtrl() { delete images; }
{
delete images;
}
int TabCtrl::GetSelection() const { return sel; } int TabCtrl::GetSelection() const { return sel; }
@@ -75,14 +68,11 @@ void TabCtrl::SelectItem(int item)
Refresh(); Refresh();
} }
void TabCtrl::Unselect() void TabCtrl::Unselect() { SelectItem(-1); }
{
SelectItem(-1);
}
void TabCtrl::Rescale() void TabCtrl::Rescale()
{ {
for (auto & b : btns) for (auto& b : btns)
b->Rescale(); b->Rescale();
relayout(); relayout();
} }
@@ -96,23 +86,20 @@ bool TabCtrl::SetFont(wxFont const& font)
return true; return true;
} }
int TabCtrl::AppendItem(const wxString &item, int TabCtrl::AppendItem(const wxString& item, int image, int selImage, void* clientData)
int image, int selImage,
void * clientData)
{ {
Button * btn = new Button(); Button* btn = new Button();
btn->Create(this, item, "", wxBORDER_NONE); btn->Create(this, item, "", wxBORDER_NONE);
btn->SetFont(GetFont()); btn->SetFont(GetFont());
btn->SetTextColor(StateColor( btn->SetTextColor(
std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), StateColor(std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal)));
std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal)));
btn->SetBackgroundColor(StateColor()); btn->SetBackgroundColor(StateColor());
btn->SetCornerRadius(0); btn->SetCornerRadius(0);
btn->SetPaddingSize({TAB_BUTTON_PADDING}); btn->SetPaddingSize({TAB_BUTTON_PADDING});
btns.push_back(btn); btns.push_back(btn);
if (btns.size() > 1) if (btns.size() > 1)
sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); 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); sizer->AddStretchSpacer(1);
relayout(); relayout();
return btns.size() - 1; return btns.size() - 1;
@@ -144,7 +131,7 @@ bool TabCtrl::DeleteItem(int item)
sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0});
if (selection_changed) { 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(); relayout();
if (selection_changed) { if (selection_changed) {
@@ -167,14 +154,12 @@ void TabCtrl::DeleteAllItems()
unsigned int TabCtrl::GetCount() const { return btns.size(); } unsigned int TabCtrl::GetCount() const { return btns.size(); }
wxString TabCtrl::GetItemText(unsigned int item) const wxString TabCtrl::GetItemText(unsigned int item) const { return item < btns.size() ? btns[item]->GetLabel() : wxString{}; }
{
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); btns[item]->SetLabel(value);
} }
@@ -188,61 +173,59 @@ void TabCtrl::SetItemBitmap(unsigned int item, const wxBitmap& bitmap)
bool TabCtrl::GetItemBold(unsigned int item) const bool TabCtrl::GetItemBold(unsigned int item) const
{ {
if (item >= btns.size()) return false; if (item >= btns.size())
return false;
return btns[item]->GetFont() == bold; return btns[item]->GetFont() == bold;
} }
void TabCtrl::SetItemBold(unsigned int item, bool 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]->SetFont(bold ? this->bold : GetFont());
btns[item]->Rescale(); btns[item]->Rescale();
} }
void* TabCtrl::GetItemData(unsigned int item) const void* TabCtrl::GetItemData(unsigned int item) const
{ {
if (item >= btns.size()) return nullptr; if (item >= btns.size())
return nullptr;
return btns[item]->GetClientData(); return btns[item]->GetClientData();
} }
void TabCtrl::SetItemData(unsigned int item, void* clientData) void TabCtrl::SetItemData(unsigned int item, void* clientData)
{ {
if (item >= btns.size()) return; if (item >= btns.size())
return;
btns[item]->SetClientData(clientData); btns[item]->SetClientData(clientData);
} }
void TabCtrl::AssignImageList(wxImageList* imageList) void TabCtrl::AssignImageList(wxImageList* imageList)
{ {
if (images == imageList) return; if (images == imageList)
return;
delete images; delete images;
images = imageList; 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); btns[item]->SetTextColor(col);
} }
int TabCtrl::GetFirstVisibleItem() const int TabCtrl::GetFirstVisibleItem() const { return btns.size() == 0 ? -1 : 0; }
{
return btns.size() == 0 ? -1 : 0;
}
int TabCtrl::GetNextVisible(int item) const int TabCtrl::GetNextVisible(int item) const { return ++item < btns.size() ? item : -1; }
{
return ++item < btns.size() ? item : -1;
}
bool TabCtrl::IsVisible(unsigned int item) const bool TabCtrl::IsVisible(unsigned int item) const { return true; }
{
return true;
}
void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags)
{ {
wxWindow::DoSetSize(x, y, width, height, sizeFlags); wxWindow::DoSetSize(x, y, width, height, sizeFlags);
if (sizeFlags & wxSIZE_USE_EXISTING) return; if (sizeFlags & wxSIZE_USE_EXISTING)
return;
relayout(); 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) 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); return wxWindow::MSWWindowProc(nMsg, wParam, lParam);
} }
@@ -259,15 +244,15 @@ WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam)
void TabCtrl::relayout() void TabCtrl::relayout()
{ {
int offset = 10; int offset = 10;
int item = sel + 1; int item = sel + 1;
int first = 0; int first = 0;
for (int i = 0; i < item; ++i) 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()) if (item < btns.size())
offset += btns[item]->GetMinSize().x + TAB_BUTTON_SPACE * 2; offset += btns[item]->GetMinSize().x + item_space * 2;
int width = GetSize().x; int width = GetSize().x;
for (int i = 0; i < btns.size(); ++i) { 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) { if (i < sel && offset > width) {
sizer->Show(i * 2 + 1, false); sizer->Show(i * 2 + 1, false);
sizer->Show(i * 2 + 2, false); sizer->Show(i * 2 + 2, false);
@@ -288,23 +273,32 @@ void TabCtrl::relayout()
sizer->GetItem(i * 2 + 2)->SetMinSize({0, 0}); sizer->GetItem(i * 2 + 2)->SetMinSize({0, 0});
} }
if (item >= btns.size()) if (item >= btns.size())
-- item; --item;
// Keep spacing 2 ~ 10 TAB_BUTTON_SPACE // 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}); sizer->GetItem(item * 2 + 2)->SetMinSize({b > 0 ? b : 0, 0});
Layout(); 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. // Mirrors relayout(): a 10px leading spacer plus every button's min width and spacing.
int width = 10; int width = 10;
for (const Button *btn : btns) for (const Button* btn : btns)
width += btn->GetMinSize().x + TAB_BUTTON_SPACE * 2; width += btn->GetMinSize().x + item_space * 2;
return width; return width;
} }
void TabCtrl::buttonClicked(wxCommandEvent &event) void TabCtrl::buttonClicked(wxCommandEvent& event)
{ {
SetFocus(); SetFocus();
auto btn = event.GetEventObject(); auto btn = event.GetEventObject();
@@ -312,7 +306,7 @@ void TabCtrl::buttonClicked(wxCommandEvent &event)
SelectItem(iter == btns.end() ? -1 : iter - btns.begin()); SelectItem(iter == btns.end() ? -1 : iter - btns.begin());
} }
void TabCtrl::keyDown(wxKeyEvent &event) void TabCtrl::keyDown(wxKeyEvent& event)
{ {
switch (event.GetKeyCode()) { switch (event.GetKeyCode()) {
case WXK_UP: case WXK_UP:
@@ -331,11 +325,13 @@ void TabCtrl::keyDown(wxKeyEvent &event)
void TabCtrl::doRender(wxDC& dc) void TabCtrl::doRender(wxDC& dc)
{ {
wxSize size = GetSize(); wxSize size = GetSize();
int states = state_handler.states(); int states = state_handler.states();
if (sel < 0) { return; } if (sel < 0) {
return;
}
auto x1 = btns[sel]->GetPosition().x; auto x1 = btns[sel]->GetPosition().x;
auto x2 = x1 + btns[sel]->GetSize().x; auto x2 = x1 + btns[sel]->GetSize().x;
const int BS2 = (1 + border_width) / 2; const int BS2 = (1 + border_width) / 2;
#if 0 #if 0
const int BS = border_width / 2; const int BS = border_width / 2;

View File

@@ -3,33 +3,30 @@
#include "Button.hpp" #include "Button.hpp"
wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent);
wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent);
class TabCtrl : public StaticBox class TabCtrl : public StaticBox
{ {
std::vector<Button*> btns; std::vector<Button*> btns;
wxImageList* images = nullptr; wxImageList* images = nullptr;
wxBoxSizer * sizer = nullptr; wxBoxSizer* sizer = nullptr;
int sel = -1; int sel = -1;
wxFont bold; wxFont bold;
int item_space = 2; // space around each button, both sides (SetItemSpace)
public: public:
TabCtrl(wxWindow * parent, TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = 0);
wxWindowID id,
const wxPoint & pos = wxDefaultPosition,
const wxSize & size = wxDefaultSize,
long style = 0);
~TabCtrl(); ~TabCtrl();
public: public:
virtual bool SetFont(wxFont const & font) override; virtual bool SetFont(wxFont const& font) override;
public: public:
int AppendItem(const wxString &item, int image = -1, int selImage = -1, 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); int AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData = nullptr);
bool DeleteItem(int item); bool DeleteItem(int item);
@@ -37,7 +34,7 @@ public:
unsigned int GetCount() const; unsigned int GetCount() const;
int GetSelection() const; int GetSelection() const;
void SelectItem(int item); void SelectItem(int item);
@@ -46,16 +43,16 @@ public:
virtual void Rescale(); virtual void Rescale();
wxString GetItemText(unsigned int item) const; wxString GetItemText(unsigned int item) const;
void SetItemText(unsigned int item, wxString const &value); void SetItemText(unsigned int item, wxString const& value);
void SetItemBitmap(unsigned int item, const wxBitmap& bitmap); void SetItemBitmap(unsigned int item, const wxBitmap& bitmap);
bool GetItemBold(unsigned int item) const; bool GetItemBold(unsigned int item) const;
void SetItemBold(unsigned int item, bool bold); void SetItemBold(unsigned int item, bool bold);
void* GetItemData(unsigned int item) const; void* GetItemData(unsigned int item) const;
void SetItemData(unsigned int item, void *clientData); void SetItemData(unsigned int item, void* clientData);
void AssignImageList(wxImageList *imageList); void AssignImageList(wxImageList* imageList);
void SetItemTextColour(unsigned int item, const StateColor& col); void SetItemTextColour(unsigned int item, const StateColor& col);
@@ -64,8 +61,11 @@ public:
int GetNextVisible(int item) const; int GetNextVisible(int item) const;
bool IsVisible(unsigned 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). // Extra space around each tab button (in px on both sides). Defaults to the control-wide
int buttons_best_width() const; // standard; call before appending items so every button picks it up.
void SetItemSpace(int space);
int GetFullSize() const;
private: private:
virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override;
@@ -76,10 +76,10 @@ private:
void relayout(); void relayout();
void buttonClicked(wxCommandEvent & event); void buttonClicked(wxCommandEvent& event);
void keyDown(wxKeyEvent &event); void keyDown(wxKeyEvent& event);
void doRender(wxDC & dc) override; void doRender(wxDC& dc) override;
// some useful events // some useful events
bool sendTabCtrlEvent(bool changing = false); bool sendTabCtrlEvent(bool changing = false);

View File

@@ -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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222", "#333333" };
full_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
full_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
full_cfg.opt<ConfigOptionBools>("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<ConfigOptionBools>("filament_is_mixed")->values == std::vector<unsigned char>{ 0, 0, 1 });
const auto& components = filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 3);
CHECK(components[2] == "1,2");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9");
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values[2]);
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values[2]);
}
THEN("the non-published slots are masked to their defaults") {
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[0] == "");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[1] == "");
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[0] == 0);
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[1] == 0);
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}

View File

@@ -2545,3 +2545,252 @@ TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count
CHECK(pub.skipped_keys.empty()); 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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
};
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
config.opt<ConfigOptionBools>("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<ConfigOptionBools>("filament_is_mixed")->values;
REQUIRE(is_mixed.size() == 3);
CHECK(is_mixed[2]);
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 3);
CHECK(components[2] == "1,2");
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
REQUIRE(ratios.size() == 3);
CHECK(ratios[2] == "0.6,0.4");
const auto &gradient = bundle.project_config.opt<ConfigOptionBools>("filament_mixed_gradient")->values;
CHECK(gradient[2]);
const auto &range = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values;
CHECK(range[2] == "0.9,0.1");
const auto &curve = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values;
CHECK(curve[2] == "0,0.1|1,0.9");
const auto &per_part = bundle.project_config.opt<ConfigOptionBools>("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<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values;
REQUIRE(is_mixed.size() == 3);
CHECK(is_mixed[2]);
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
config.opt<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
// Author has two extruders.
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9 };
config.opt<ConfigOptionFloats>("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<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("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<ConfigOptionFloats>("retraction_length")->values, { 0.6 });
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("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<ConfigOptionFloats>("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<ConfigOptionFloats>("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<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
// Author has two extruders.
config.opt<ConfigOptionFloats>("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<ConfigOptionFloats>("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<ConfigOptionFloats>("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<ConfigOptionFloats>("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<ConfigOptionFloats>("retraction_length")->values, { 0.6, 0.9, 0.7 });
CHECK(pub.skipped_keys.empty());
}
}