added support for multi perimeter patterns, issue 34

This commit is contained in:
Rad
2026-03-12 03:49:38 +01:00
parent 95f6cb2574
commit 9f9aebf957
8 changed files with 482 additions and 48 deletions

View File

@@ -170,7 +170,9 @@ public:
, m_can_reverse(rhs.m_can_reverse)
, m_role(rhs.m_role)
, m_no_extrusion(rhs.m_no_extrusion)
{}
{
this->inset_idx = rhs.inset_idx;
}
ExtrusionPath(ExtrusionPath &&rhs)
: polyline(std::move(rhs.polyline))
, mm3_per_mm(rhs.mm3_per_mm)
@@ -179,7 +181,9 @@ public:
, m_can_reverse(rhs.m_can_reverse)
, m_role(rhs.m_role)
, m_no_extrusion(rhs.m_no_extrusion)
{}
{
this->inset_idx = rhs.inset_idx;
}
ExtrusionPath(const Polyline &polyline, const ExtrusionPath &rhs)
: polyline(polyline)
, mm3_per_mm(rhs.mm3_per_mm)
@@ -188,7 +192,9 @@ public:
, m_can_reverse(rhs.m_can_reverse)
, m_role(rhs.m_role)
, m_no_extrusion(rhs.m_no_extrusion)
{}
{
this->inset_idx = rhs.inset_idx;
}
ExtrusionPath(Polyline &&polyline, const ExtrusionPath &rhs)
: polyline(std::move(polyline))
, mm3_per_mm(rhs.mm3_per_mm)
@@ -197,7 +203,9 @@ public:
, m_can_reverse(rhs.m_can_reverse)
, m_role(rhs.m_role)
, m_no_extrusion(rhs.m_no_extrusion)
{}
{
this->inset_idx = rhs.inset_idx;
}
ExtrusionPath& operator=(const ExtrusionPath& rhs) {
m_can_reverse = rhs.m_can_reverse;
@@ -207,6 +215,7 @@ public:
this->width = rhs.width;
this->height = rhs.height;
this->polyline = rhs.polyline;
this->inset_idx = rhs.inset_idx;
return *this;
}
ExtrusionPath& operator=(ExtrusionPath&& rhs) {
@@ -217,6 +226,7 @@ public:
this->width = rhs.width;
this->height = rhs.height;
this->polyline = std::move(rhs.polyline);
this->inset_idx = rhs.inset_idx;
return *this;
}

View File

@@ -4008,6 +4008,53 @@ static bool split_extrusion_collection_for_pointillism_paths(
return out_stats.segment_count > 0;
}
static bool split_extrusion_collection_for_multi_perimeter_pattern(
const ExtrusionEntityCollection& source,
const MixedFilamentManager& mixed_mgr,
unsigned int mixed_filament_id,
size_t num_physical,
int layer_index,
std::vector<std::unique_ptr<ExtrusionEntityCollection>>& out_by_extruder,
size_t& out_bucket_count)
{
out_by_extruder.clear();
out_by_extruder.resize(num_physical);
out_bucket_count = 0;
if (source.entities.empty() || num_physical == 0)
return false;
size_t split_entities = 0;
ExtrusionEntityCollection flattened = source.flatten(false);
for (const ExtrusionEntity* entity : flattened.entities) {
if (entity == nullptr)
continue;
int perimeter_index = entity->inset_idx;
if (perimeter_index < 0)
perimeter_index = entity->role() == erExternalPerimeter ? 0 : 1;
const unsigned int extruder_id = mixed_mgr.resolve_perimeter(
mixed_filament_id, num_physical, layer_index, perimeter_index);
if (extruder_id == 0 || extruder_id > num_physical)
continue;
std::unique_ptr<ExtrusionEntityCollection>& bucket = out_by_extruder[extruder_id - 1];
if (!bucket) {
bucket = std::make_unique<ExtrusionEntityCollection>();
bucket->no_sort = source.no_sort;
}
bucket->append(*entity);
++split_entities;
}
for (const std::unique_ptr<ExtrusionEntityCollection>& bucket : out_by_extruder) {
if (bucket && !bucket->entities.empty())
++out_bucket_count;
}
return split_entities > 0;
}
inline std::vector<GCode::ObjectByExtruder::Island>& object_islands_by_extruder(
std::map<unsigned int, std::vector<GCode::ObjectByExtruder>>& by_extruder,
unsigned int extruder_id,
@@ -4750,6 +4797,27 @@ LayerResult GCode::process_layer(const Print& print,
auto inserted = pointillism_sequence_cache.emplace(filament_id_1based, std::move(sequence));
return inserted.first->second.empty() ? nullptr : &inserted.first->second;
};
auto grouped_manual_pattern_mixed_filament_id =
[&layer_tools, &configured_filament_id_1based](const ExtrusionEntityCollection& entities,
const PrintRegion& region) -> unsigned int {
if (layer_tools.mixed_mgr == nullptr || layer_tools.num_physical == 0)
return 0;
auto has_grouped_pattern = [&layer_tools](unsigned int filament_id_1based) -> bool {
if (!layer_tools.mixed_mgr->is_mixed(filament_id_1based, layer_tools.num_physical))
return false;
const MixedFilament* mixed_row = layer_tools.mixed_mgr->mixed_filament_from_id(filament_id_1based, layer_tools.num_physical);
if (mixed_row == nullptr)
return false;
const std::string normalized_pattern = MixedFilamentManager::normalize_manual_pattern(mixed_row->manual_pattern);
return normalized_pattern.find(',') != std::string::npos;
};
const unsigned int configured_filament_id = configured_filament_id_1based(entities, region);
if (has_grouped_pattern(configured_filament_id))
return configured_filament_id;
return 0;
};
// Compensate perimeter clipping at mixed-mask boundaries to avoid cracks from exact centerline clipping.
constexpr double LOCAL_Z_PERIMETER_MASK_EXPAND_MM = 0.10;
// Keep base exclusion smaller than mixed-pass inclusion to guarantee a slight overlap
@@ -5216,6 +5284,47 @@ LayerResult GCode::process_layer(const Print& print,
// This extrusion is part of certain Region, which tells us which extruder should be used for it:
int correct_extruder_id = layer_tools.extruder(*filtered_extrusions, region);
if (!is_anything_overridden &&
entity_type == ObjectByExtruder::Island::Region::PERIMETERS &&
layer_tools.mixed_mgr != nullptr &&
layer_tools.num_physical > 0 &&
correct_extruder_id >= 0) {
const unsigned int mixed_filament_id =
grouped_manual_pattern_mixed_filament_id(*filtered_extrusions, region);
if (mixed_filament_id != 0) {
std::vector<std::unique_ptr<ExtrusionEntityCollection>> split_by_extruder;
size_t bucket_count = 0;
if (split_extrusion_collection_for_multi_perimeter_pattern(*filtered_extrusions,
*layer_tools.mixed_mgr,
mixed_filament_id,
layer_tools.num_physical,
layer_tools.layer_index,
split_by_extruder,
bucket_count) &&
bucket_count >= 2) {
for (size_t extruder_idx = 0; extruder_idx < split_by_extruder.size(); ++extruder_idx) {
std::unique_ptr<ExtrusionEntityCollection>& split_collection = split_by_extruder[extruder_idx];
if (!split_collection || split_collection->entities.empty())
continue;
const ExtrusionEntityCollection* split_ptr = split_collection.get();
local_z_clipped_collections.emplace_back(std::move(split_collection));
std::vector<ObjectByExtruder::Island>& islands =
object_islands_by_extruder(by_extruder, unsigned(extruder_idx), layer_to_print_idx, layers.size(), n_slices + 1);
for (size_t i = 0; i <= n_slices; ++i) {
const bool last = i == n_slices;
const size_t island_idx = last ? n_slices : slices_test_order[i];
if (last || point_inside_surface(island_idx, split_ptr->first_point())) {
if (islands[island_idx].by_region.empty())
islands[island_idx].by_region.assign(print.num_print_regions(), ObjectByExtruder::Island::Region());
islands[island_idx].by_region[region.print_region_id()].append(entity_type, split_ptr, nullptr);
break;
}
}
}
continue;
}
}
}
// Let's recover vector of extruder overrides:
const WipingExtrusions::ExtruderPerCopy* entity_overrides = nullptr;

View File

@@ -60,6 +60,45 @@ unsigned int resolve_mixed_with_layer_heights(const MixedFilamentManager *mixed_
return mixed_mgr->resolve(filament_id_1based, num_physical, layer_index, layer_print_z, layer_height);
}
bool has_grouped_manual_pattern(const MixedFilamentManager *mixed_mgr,
size_t num_physical,
unsigned int filament_id_1based)
{
if (!(mixed_mgr && mixed_mgr->is_mixed(filament_id_1based, num_physical)))
return false;
const MixedFilament *mixed_row = mixed_mgr->mixed_filament_from_id(filament_id_1based, num_physical);
if (mixed_row == nullptr)
return false;
const std::string normalized = MixedFilamentManager::normalize_manual_pattern(mixed_row->manual_pattern);
return normalized.find(',') != std::string::npos;
}
void append_unique_preserve_order(std::vector<unsigned int> &dst, unsigned int value)
{
if (std::find(dst.begin(), dst.end(), value) == dst.end())
dst.emplace_back(value);
}
unsigned int grouped_manual_pattern_mixed_filament_id_for_layer(const LayerTools& layer_tools,
unsigned int configured_filament_id_1based)
{
if (layer_tools.mixed_mgr == nullptr || layer_tools.num_physical == 0)
return 0;
if (has_grouped_manual_pattern(layer_tools.mixed_mgr, layer_tools.num_physical, configured_filament_id_1based))
return configured_filament_id_1based;
return 0;
}
void remove_duplicates_preserve_order(std::vector<unsigned int> &values)
{
std::vector<unsigned int> ordered;
ordered.reserve(values.size());
for (unsigned int value : values)
append_unique_preserve_order(ordered, value);
values = std::move(ordered);
}
} // namespace
@@ -639,11 +678,34 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto
}
if (something_nonoverriddable){
unsigned int wall_ext = (extruder_override == 0) ? region.config().wall_filament.value : extruder_override;
wall_ext = resolve_mixed(wall_ext, layerCount, float(layer->print_z), float(layer->height));
layer_tools.extruders.emplace_back(wall_ext);
if (layerCount == 0) {
firstLayerExtruders.emplace_back(wall_ext);
const unsigned int configured_wall = (extruder_override == 0) ? region.config().wall_filament.value : extruder_override;
unsigned int wall_ext = resolve_mixed(configured_wall, layerCount, float(layer->print_z), float(layer->height));
const unsigned int grouped_id =
grouped_manual_pattern_mixed_filament_id_for_layer(layer_tools, configured_wall);
if (grouped_id != 0) {
const std::vector<unsigned int> ordered =
m_mixed_mgr->ordered_perimeter_extruders(grouped_id,
m_num_physical,
layerCount,
float(layer->print_z),
float(layer->height));
if (ordered.size() >= 2) {
layer_tools.preserve_extruder_order = true;
for (unsigned int extruder_id : ordered) {
layer_tools.extruders.emplace_back(extruder_id);
if (layerCount == 0 &&
std::find(firstLayerExtruders.begin(), firstLayerExtruders.end(), int(extruder_id)) == firstLayerExtruders.end())
firstLayerExtruders.emplace_back(int(extruder_id));
}
} else {
layer_tools.extruders.emplace_back(wall_ext);
if (layerCount == 0)
firstLayerExtruders.emplace_back(wall_ext);
}
} else {
layer_tools.extruders.emplace_back(wall_ext);
if (layerCount == 0)
firstLayerExtruders.emplace_back(wall_ext);
}
}
@@ -696,8 +758,10 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto
const_cast<PrintObject&>(object).object_first_layer_wall_extruders = firstLayerExtruders;
for (auto& layer : m_layer_tools) {
// Sort and remove duplicates
sort_remove_duplicates(layer.extruders);
if (layer.preserve_extruder_order)
remove_duplicates_preserve_order(layer.extruders);
else
sort_remove_duplicates(layer.extruders);
// make sure that there are some tools for each object layer (e.g. tall wiping object will result in empty extruders vector)
if (layer.extruders.empty() && layer.has_object)
@@ -739,6 +803,10 @@ void ToolOrdering::reorder_extruders(unsigned int last_extruder_id)
if (lt.extruders.front() == 0)
// Pop the "don't care" extruder, the "don't care" region will be merged with the next one.
lt.extruders.erase(lt.extruders.begin());
if (lt.preserve_extruder_order) {
last_extruder_id = lt.extruders.back();
continue;
}
// Reorder the extruders to start with the last one.
for (size_t i = 1; i < lt.extruders.size(); ++ i)
if (lt.extruders[i] == last_extruder_id) {
@@ -789,27 +857,29 @@ void ToolOrdering::reorder_extruders(std::vector<unsigned int> tool_order_layer0
// Reorder the extruders of first layer
{
LayerTools& lt = m_layer_tools[0];
std::vector<unsigned int> layer0_extruders = lt.extruders;
lt.extruders.clear();
for (unsigned int extruder_id : tool_order_layer0) {
auto iter = std::find(layer0_extruders.begin(), layer0_extruders.end(), extruder_id);
if (iter != layer0_extruders.end()) {
lt.extruders.push_back(extruder_id);
*iter = (unsigned int)-1;
if (!lt.preserve_extruder_order) {
std::vector<unsigned int> layer0_extruders = lt.extruders;
lt.extruders.clear();
for (unsigned int extruder_id : tool_order_layer0) {
auto iter = std::find(layer0_extruders.begin(), layer0_extruders.end(), extruder_id);
if (iter != layer0_extruders.end()) {
lt.extruders.push_back(extruder_id);
*iter = (unsigned int)-1;
}
}
}
for (unsigned int extruder_id : layer0_extruders) {
if (extruder_id == 0)
continue;
for (unsigned int extruder_id : layer0_extruders) {
if (extruder_id == 0)
continue;
if (extruder_id != (unsigned int)-1)
lt.extruders.push_back(extruder_id);
}
if (extruder_id != (unsigned int)-1)
lt.extruders.push_back(extruder_id);
}
// all extruders are zero
if (lt.extruders.empty()) {
lt.extruders.push_back(tool_order_layer0[0]);
// all extruders are zero
if (lt.extruders.empty()) {
lt.extruders.push_back(tool_order_layer0[0]);
}
}
}
@@ -825,6 +895,10 @@ void ToolOrdering::reorder_extruders(std::vector<unsigned int> tool_order_layer0
if (lt.extruders.front() == 0)
// Pop the "don't care" extruder, the "don't care" region will be merged with the next one.
lt.extruders.erase(lt.extruders.begin());
if (lt.preserve_extruder_order) {
last_extruder_id = lt.extruders.back();
continue;
}
// Reorder the extruders to start with the last one.
for (size_t i = 1; i < lt.extruders.size(); ++i)
if (lt.extruders[i] == last_extruder_id) {
@@ -1039,6 +1113,10 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume()
LayerTools& lt = m_layer_tools[i];
if (lt.extruders.empty())
continue;
if (lt.preserve_extruder_order) {
current_extruder_id = lt.extruders.back();
continue;
}
std::vector<int> custom_extruder_seq;
if (get_custom_seq(i, custom_extruder_seq) && !custom_extruder_seq.empty()) {

View File

@@ -117,6 +117,7 @@ public:
bool has_support = false;
// Zero based extruder IDs, ordered to minimize tool switches.
std::vector<unsigned int> extruders;
bool preserve_extruder_order = false;
// If per layer extruder switches are inserted by the G-code preview slider, this value contains the new (1 based) extruder, with which the whole object layer is being printed with.
// If not overriden, it is set to 0.
unsigned int extruder_override = 0;

View File

@@ -383,7 +383,7 @@ static bool parse_row_definition(const std::string &row,
while (std::getline(ss, token, ','))
tokens.emplace_back(trim_copy(token));
if (tokens.size() < 4 || tokens.size() > 13)
if (tokens.size() < 4)
return false;
int values[5] = { 0, 0, 1, 1, 50 };
@@ -436,6 +436,10 @@ static bool parse_row_definition(const std::string &row,
}
}
std::vector<std::string> pattern_tokens;
pattern_tokens.reserve(tokens.size() > token_idx ? tokens.size() - token_idx : 1);
if (!manual_pattern.empty())
pattern_tokens.push_back(manual_pattern);
for (size_t i = token_idx; i < tokens.size(); ++i) {
const std::string &tok = tokens[i];
if (tok.empty())
@@ -472,7 +476,17 @@ static bool parse_row_definition(const std::string &row,
stable_id = parsed_stable_id;
continue;
}
manual_pattern = tok;
pattern_tokens.push_back(tok);
}
if (!pattern_tokens.empty()) {
std::ostringstream joined_pattern;
for (size_t i = 0; i < pattern_tokens.size(); ++i) {
if (i != 0)
joined_pattern << ',';
joined_pattern << pattern_tokens[i];
}
manual_pattern = joined_pattern.str();
}
// Compatibility for early same-layer prototype rows.
@@ -504,14 +518,69 @@ static bool decode_pattern_step(char c, char &out)
}
}
static std::vector<std::string> split_manual_pattern_groups(const std::string &pattern)
{
std::vector<std::string> groups;
if (pattern.empty())
return groups;
std::string current;
for (const char c : pattern) {
if (c == ',') {
if (!current.empty()) {
groups.emplace_back(std::move(current));
current.clear();
}
continue;
}
current.push_back(c);
}
if (!current.empty())
groups.emplace_back(std::move(current));
return groups;
}
static std::string flatten_manual_pattern_groups(const std::string &pattern)
{
std::string flattened;
flattened.reserve(pattern.size());
for (const char c : pattern)
if (c != ',')
flattened.push_back(c);
return flattened;
}
static unsigned int physical_filament_from_pattern_step(char token, const MixedFilament &mf, size_t num_physical)
{
if (token == '1')
return mf.component_a;
if (token == '2')
return mf.component_b;
if (token >= '3' && token <= '9') {
const unsigned int direct = unsigned(token - '0');
if (direct >= 1 && direct <= num_physical)
return direct;
}
return 0;
}
static int mix_percent_from_normalized_pattern(const std::string &pattern)
{
if (pattern.empty())
const std::vector<std::string> groups = split_manual_pattern_groups(pattern);
if (groups.empty())
return 50;
// Legacy blend ratio for UI preview: count component-B aliases only.
// Tokens '3'..'9' are direct physical filament IDs and are ignored here.
const int count_b = int(std::count(pattern.begin(), pattern.end(), '2'));
return clamp_int(int(std::lround(100.0 * double(count_b) / double(pattern.size()))), 0, 100);
// For grouped patterns, blend preview is the average of each perimeter
// group's own cadence. This keeps simple outer/inner patterns like
// "12,21" at 50/50 and "11111112,11121111" at 12.5%.
double blend_b = 0.0;
for (const std::string &group : groups) {
if (group.empty())
continue;
const int count_b = int(std::count(group.begin(), group.end(), '2'));
blend_b += double(count_b) / double(group.size());
}
return clamp_int(int(std::lround(100.0 * blend_b / double(groups.size()))), 0, 100);
}
static std::string normalize_gradient_component_ids(const std::string &components)
@@ -859,10 +928,19 @@ std::string MixedFilamentManager::normalize_manual_pattern(const std::string &pa
{
std::string normalized;
normalized.reserve(pattern.size());
bool current_group_has_steps = false;
for (char c : pattern) {
char step = '\0';
if (decode_pattern_step(c, step)) {
normalized.push_back(step);
current_group_has_steps = true;
continue;
}
if (c == ',') {
if (!current_group_has_steps)
return std::string();
normalized.push_back(',');
current_group_has_steps = false;
continue;
}
if (is_pattern_separator(c))
@@ -870,9 +948,16 @@ std::string MixedFilamentManager::normalize_manual_pattern(const std::string &pa
// Unknown token => invalid pattern.
return std::string();
}
if (!normalized.empty() && normalized.back() == ',')
return std::string();
return normalized;
}
int MixedFilamentManager::mix_percent_from_manual_pattern(const std::string &pattern)
{
return mix_percent_from_normalized_pattern(normalize_manual_pattern(pattern));
}
void MixedFilamentManager::apply_gradient_settings(int gradient_mode,
float lower_bound,
float upper_bound,
@@ -1119,16 +1204,12 @@ unsigned int MixedFilamentManager::resolve(unsigned int filament_id,
// steps: '1' => component_a, '2' => component_b, '3'..'9' => direct
// physical filament IDs.
if (!mf.manual_pattern.empty()) {
const int pos = safe_mod(layer_index, int(mf.manual_pattern.size()));
const char token = mf.manual_pattern[size_t(pos)];
if (token == '2')
return mf.component_b;
if (token == '1')
return mf.component_a;
if (token >= '3' && token <= '9') {
const unsigned int direct = unsigned(token - '0');
if (direct >= 1 && direct <= num_physical)
return direct;
const std::string flattened_pattern = flatten_manual_pattern_groups(mf.manual_pattern);
if (!flattened_pattern.empty()) {
const int pos = safe_mod(layer_index, int(flattened_pattern.size()));
const unsigned int resolved = physical_filament_from_pattern_step(flattened_pattern[size_t(pos)], mf, num_physical);
if (resolved >= 1 && resolved <= num_physical)
return resolved;
}
return mf.component_a;
}
@@ -1174,6 +1255,78 @@ unsigned int MixedFilamentManager::resolve(unsigned int filament_id,
return (pos < mf.ratio_a) ? mf.component_a : mf.component_b;
}
unsigned int MixedFilamentManager::resolve_perimeter(unsigned int filament_id,
size_t num_physical,
int layer_index,
int perimeter_index,
float layer_print_z,
float layer_height,
bool force_height_weighted) const
{
const int mixed_idx = mixed_index_from_filament_id(filament_id, num_physical);
if (mixed_idx < 0)
return filament_id;
const MixedFilament &mf = m_mixed[size_t(mixed_idx)];
if (!mf.manual_pattern.empty()) {
const std::vector<std::string> pattern_groups = split_manual_pattern_groups(mf.manual_pattern);
if (!pattern_groups.empty()) {
const size_t group_idx = size_t(std::max(0, perimeter_index));
const std::string &group = pattern_groups[std::min(group_idx, pattern_groups.size() - 1)];
if (!group.empty()) {
const int pos = safe_mod(layer_index, int(group.size()));
const unsigned int resolved = physical_filament_from_pattern_step(group[size_t(pos)], mf, num_physical);
if (resolved >= 1 && resolved <= num_physical)
return resolved;
}
}
}
return resolve(filament_id, num_physical, layer_index, layer_print_z, layer_height, force_height_weighted);
}
std::vector<unsigned int> MixedFilamentManager::ordered_perimeter_extruders(unsigned int filament_id,
size_t num_physical,
int layer_index,
float layer_print_z,
float layer_height,
bool force_height_weighted) const
{
std::vector<unsigned int> ordered;
const int mixed_idx = mixed_index_from_filament_id(filament_id, num_physical);
if (mixed_idx < 0) {
ordered.emplace_back(filament_id);
return ordered;
}
const MixedFilament &mf = m_mixed[size_t(mixed_idx)];
if (!mf.manual_pattern.empty()) {
const std::vector<std::string> pattern_groups = split_manual_pattern_groups(mf.manual_pattern);
if (!pattern_groups.empty()) {
ordered.reserve(pattern_groups.size());
for (size_t group_idx = 0; group_idx < pattern_groups.size(); ++group_idx) {
const unsigned int resolved = resolve_perimeter(filament_id,
num_physical,
layer_index,
int(group_idx),
layer_print_z,
layer_height,
force_height_weighted);
if (resolved < 1 || resolved > num_physical)
continue;
if (std::find(ordered.begin(), ordered.end(), resolved) == ordered.end())
ordered.emplace_back(resolved);
}
if (!ordered.empty())
return ordered;
}
}
ordered.emplace_back(resolve(filament_id, num_physical, layer_index, layer_print_z, layer_height, force_height_weighted));
return ordered;
}
int MixedFilamentManager::mixed_index_from_filament_id(unsigned int filament_id, size_t num_physical) const
{
if (filament_id <= num_physical)

View File

@@ -146,6 +146,7 @@ public:
// Normalize a manual mixed-pattern string into compact token form.
// Accepts separators and A/B aliases. Returns empty string if invalid.
static std::string normalize_manual_pattern(const std::string &pattern);
static int mix_percent_from_manual_pattern(const std::string &pattern);
// ---- Queries --------------------------------------------------------
@@ -164,6 +165,19 @@ public:
float layer_print_z = 0.f,
float layer_height = 0.f,
bool force_height_weighted = false) const;
unsigned int resolve_perimeter(unsigned int filament_id,
size_t num_physical,
int layer_index,
int perimeter_index,
float layer_print_z = 0.f,
float layer_height = 0.f,
bool force_height_weighted = false) const;
std::vector<unsigned int> ordered_perimeter_extruders(unsigned int filament_id,
size_t num_physical,
int layer_index,
float layer_print_z = 0.f,
float layer_height = 0.f,
bool force_height_weighted = false) const;
// Map virtual filament ID (1-based, after physical IDs) to index into
// m_mixed. Virtual IDs enumerate enabled mixed rows only.