feat(orient): auto-orient the largest overhang toward the cooling fan

New printer option fan_direction (undefine/left/right/both, default
undefine) declares which side the auxiliary part-cooling airflow comes
from. When set and the printer has an auxiliary fan, auto-orient adds a
yaw rotation so the dominant overhang area faces the airflow, and newly
added primitive shapes are pre-oriented the same way (except the Cube,
whose axis-aligned bounding box the pressure-advance pattern calibration
depends on).

- FanDirection enum + fan_direction printer option (Accessory group,
  enabled only with auxiliary_fan)
- orient engine: weighted overhang areas per candidate, yaw-direction
  search, vertical rotation applied on top of the primary orientation;
  the cooling weights are taken from the candidate actually chosen,
  including the flat-bottom tie-break
- orient_for_cooling() for primitive placement
- set fan_direction=left on H2C/H2D/H2D Pro/X1/X1E/P1S 0.4 profiles
  (X1C/H2S/P2S/X2D/Qidi X-Max 4 already carried the key, which now
  takes effect)

With fan_direction unset or no auxiliary fan the vertical rotation stays
identity and auto-orient results are unchanged; slicing and g-code are
never affected.
This commit is contained in:
SoftFever
2026-07-10 16:21:48 +08:00
parent 93a81179a1
commit b12aad6a8c
15 changed files with 283 additions and 19 deletions

View File

@@ -18740,3 +18740,12 @@ msgstr ""
msgid "Switch track at Filament Track Switch"
msgstr ""
msgid "Fan direction"
msgstr ""
msgid "Cooling fan direction of the printer"
msgstr ""
msgid "Both"
msgstr ""

View File

@@ -31,6 +31,7 @@
"1",
"6"
],
"fan_direction": "left",
"hotend_cooling_rate": [
"1.6",
"1.6",

View File

@@ -24,6 +24,7 @@
"0x0,325x0,325x320,0x320",
"25x0,350x0,350x320,25x320"
],
"fan_direction": "left",
"hotend_heating_rate": [
"3.6",
"3.6",

View File

@@ -27,6 +27,7 @@
"0x0,325x0,325x320,0x320",
"25x0,350x0,350x320,25x320"
],
"fan_direction": "left",
"hotend_heating_rate": [
"3.6",
"3.6",

View File

@@ -28,6 +28,7 @@
"extruder_variant_list": [
"Direct Drive Standard,Direct Drive High Flow"
],
"fan_direction": "left",
"long_retractions_when_cut": [
"0",
"0"

View File

@@ -34,6 +34,7 @@
"extruder_variant_list": [
"Direct Drive Standard,Direct Drive High Flow"
],
"fan_direction": "left",
"long_retractions_when_cut": [
"0",
"0"

View File

@@ -28,6 +28,7 @@
"extruder_variant_list": [
"Direct Drive Standard,Direct Drive High Flow"
],
"fan_direction": "left",
"long_retractions_when_cut": [
"0",
"0"

View File

@@ -27,19 +27,20 @@ namespace Slic3r {
namespace orientation {
struct CostItems {
float overhang;
float bottom;
float bottom_hull;
float contour;
float area_laf; // area_of_low_angle_faces
float area_projected; // area of projected 2D profile
float volume;
float area_total; // total area of all faces
float radius; // radius of bounding box
float height_to_bottom_hull_ratio; // affects stability, the lower the better
float unprintability;
float overhang = 0;
float bottom = 0;
float bottom_hull = 0;
float contour = 0;
float area_laf = 0; // area_of_low_angle_faces
float area_projected = 0; // area of projected 2D profile
float volume = 0;
float area_total = 0; // total area of all faces
float radius = 0; // radius of bounding box
float height_to_bottom_hull_ratio = 0; // affects stability, the lower the better
float unprintability = 0;
Eigen::VectorXf areas_cooling;
CostItems(CostItems const & other) = default;
CostItems() { memset(this, 0, sizeof(*this)); }
CostItems() = default;
static std::string field_names() {
return " overhang, bottom, bothull, contour, A_laf, A_prj, unprintability";
}
@@ -68,10 +69,11 @@ public:
Eigen::VectorXf z_max, z_max_hull; // max of projected z
Eigen::VectorXf z_median; // median of projected z
Eigen::VectorXf z_mean; // mean of projected z
Eigen::VectorXf areas_cooling; // weighted areas for cool direction
std::vector<Vec3f> face_normals;
std::vector<Vec3f> face_normals_hull;
OrientParams params;
bool has_cooling_fan = false;
std::vector< Vec3f> orientations; // Vec3f == stl_normal
std::function<void(unsigned)> progressind = { }; // default empty indicator function
@@ -85,6 +87,7 @@ public:
orient_mesh = orient_mesh_;
mesh = &orient_mesh->mesh;
params = params_;
has_cooling_fan = orient_mesh->has_cooling_fan;
progressind = progressind_;
params.ASCENT = cos(PI - orient_mesh->overhang_angle * PI / 180); // use per-object overhang angle
@@ -158,12 +161,14 @@ public:
//To avoid flipping, we need to verify if there are orientations with same unprintability.
Vec3f n1 = {0, 0, 1};
auto best_orientation = results_vector[0].first;
size_t best_index = 0;
for (int i = 1; i< results_vector.size()-1; i++) {
if (abs(results_vector[i].second.unprintability - results_vector[0].second.unprintability) < EPSILON && abs(results_vector[0].first.dot(n1)-1) > EPSILON) {
if (abs(results_vector[i].first.dot(n1)-1) < EPSILON*EPSILON) {
if (abs(results_vector[i].first.dot(n1)-1) < EPSILON*EPSILON) {
best_orientation = n1;
break;
best_index = i;
break;
}
}
else {
@@ -172,6 +177,9 @@ public:
}
// cooling weights are per-orientation, so take them from the orientation actually chosen
areas_cooling = results_vector[best_index].second.areas_cooling;
BOOST_LOG_TRIVIAL(info) << std::fixed << std::setprecision(6) << "best:" << best_orientation.transpose() << ", costs:" << results_vector[0].second.field_values();
std::cout << std::fixed << std::setprecision(6) << "best:" << best_orientation.transpose() << ", costs:" << results_vector[0].second.field_values() << std::endl;
@@ -441,6 +449,19 @@ public:
Eigen::MatrixXf laf_areas = ((normal_projection_abs.array() < params.LAF_MAX) * (normal_projection_abs.array() > params.LAF_MIN) * (z_max.array() > total_min_z + params.FIRST_LAY_H)).select(areas, 0);
costs.area_laf = laf_areas.sum();
if (has_cooling_fan)
{
// Angle range of overhang faces requiring cooling
float angle_thres_high = -0.6427f;
float angle_thres_low = -0.97f;
// compute the weighted overhang faces area
Eigen::VectorXf ones_f = Eigen::VectorXf::Ones(mesh->facets_count());
auto overhang_area_condition = (normal_projection.array() < angle_thres_high && normal_projection.array() > angle_thres_low).eval();
Eigen::VectorXf areas_ = (overhang_area_condition * !bottom_condition_2nd).select(areas, 0);
Eigen::VectorXf weighted_areas = areas_.cwiseProduct(ones_f - normal_projection);
costs.areas_cooling = weighted_areas;
}
// height to bottom_hull_area ratio
//float total_max_z = z_projected.maxCoeff();
//costs.height_to_bottom_hull_ratio = SQ(total_max_z) / (costs.bottom_hull + 1e-7);
@@ -468,6 +489,67 @@ public:
return cost;
}
Vec3d find_cooling_direction2(Vec3d euler_angles, const Eigen::VectorXf& areas_in, TriangleMesh& mesh)
{
Vec3f machine_cool_dir = this->orient_mesh->cooling_direction.cast<float>();
const size_t num_faces = areas.rows();
Vec3f best_direction = { 0, 0, 0 };
// 1. Make a copy of input mesh, rotate and translate to the best orientation
TriangleMesh mesh_copy = TriangleMesh(mesh.its);
mesh_copy.rotate_x(euler_angles(0, 0));
mesh_copy.rotate_y(euler_angles(1, 0));
mesh_copy.rotate_z(euler_angles(2, 0));
auto bounding_box = mesh_copy.bounding_box();
Eigen::VectorXf translate_distance = bounding_box.min.array().cast<float>();
Vec3d mesh_center = mesh_copy.center();
mesh_copy.translate(-mesh_center(0), -mesh_center(1), -translate_distance(2));
// 2. sample cooling direction
const size_t sample_nums = 180;
std::vector<Vec3f> cool_dirs;
for (size_t i = 0; i < sample_nums; i++)
{
float angle_deg = i * (360.0 / sample_nums);
float angle_rad = angle_deg * (PI / 180.0);
cool_dirs.push_back(Vec3f{ std::cos(angle_rad), std::sin(angle_rad), 0});
}
// 3. accumulate the weighted projected overhang area, find the max weighted project area direction
std::vector<Vec3f> face_normals_copy = its_face_normals(mesh_copy.its);
float overhang_projected_max = 0.f;
float overhang_projected_origin = 0.f;
for (auto cool_dir : cool_dirs)
{
float overhang_projected_tmp = 0.f;
for (size_t i = 0; i < num_faces; i++)
{
float cool_dir_projection = face_normals_copy[i].dot(cool_dir);
if (areas_in[i] > 0 && cool_dir_projection > 0)
{
overhang_projected_tmp += areas_in[i] * cool_dir_projection;
}
}
if (overhang_projected_tmp > overhang_projected_max)
{
overhang_projected_max = overhang_projected_tmp;
best_direction = cool_dir;
}
if (cool_dir.dot(machine_cool_dir) > 0.999)
{
overhang_projected_origin = overhang_projected_tmp;
}
}
// The symmetric model has similar overhang projection at all angles, so Z-axis rotation is unnecessary.
if (std::abs(overhang_projected_origin - overhang_projected_max) < 1.0f)
{
best_direction = machine_cool_dir;
}
BOOST_LOG_TRIVIAL(info) << "best cooling dir = " << best_direction.transpose() << "\n";
return best_direction.cast<double>();
}
};
void _orient(OrientMeshs& meshs_,
@@ -497,6 +579,13 @@ void _orient(OrientMeshs& meshs_,
mesh_.orientation = orienter.process();
Geometry::rotation_from_two_vectors(mesh_.orientation, { 0,0,1 }, mesh_.axis, mesh_.angle, &mesh_.rotation_matrix);
mesh_.euler_angles = Geometry::extract_euler_angles(mesh_.rotation_matrix);
// find cool direction
if (mesh_.has_cooling_fan)
{
mesh_.orientation_vertical = orienter.find_cooling_direction2(mesh_.euler_angles, orienter.areas_cooling, mesh_.mesh);
BOOST_LOG_TRIVIAL(info) << "cooling direction: " << mesh_.orientation_vertical.transpose() << "\n";
Geometry::rotation_from_two_vectors(mesh_.orientation_vertical, mesh_.cooling_direction, mesh_.axis_vertical, mesh_.angle_vertical, &mesh_.rotation_matrix_vertical);
}
BOOST_LOG_TRIVIAL(debug) << "rotation_from_two_vectors: " << mesh_.orientation << "; " << mesh_.axis << "; " << mesh_.angle << "; euler: " << mesh_.euler_angles.transpose();
}});
}
@@ -539,6 +628,94 @@ void orient(ModelInstance* instance)
instance->rotate(rotation_matrix);
}
void orient_for_cooling(TriangleMesh& mesh, const FanDirection& fan_dir)
{
Vec3f best_direction{ 0, 0, 0 };
Vec3f machine_cool_dir{ 0, 0, 0 };
if (fan_dir == FanDirection::fdUndefine)
{
// no cooling fan, do not rotate along z axis
return;
}
else if (fan_dir == FanDirection::fdRight)
{
machine_cool_dir = { 1, 0, 0 }; // the cooling fan is on the right side.
}
else
{
// the cooling fan is on the left side or both side has cooling fans
machine_cool_dir = { -1, 0, 0 };
}
// 1. filter the overhang_areas
int nfaces = mesh.facets_count();
auto face_normals = its_face_normals(mesh.its);
Eigen::VectorXf normal_projection(nfaces, 1);
for (auto i = 0; i < nfaces; i++)
{
normal_projection(i) = face_normals[i].dot(Vec3f(0, 0, 1));
}
float angle_thres_high = -0.6427f;
float angle_thres_low = -0.97f;
// 2. compute the weighted overhang faces area
Eigen::VectorXf weighted_areas = Eigen::VectorXf::Zero(nfaces);
for (int i = 0; i < nfaces; i++)
{
if (normal_projection(i) < angle_thres_high && normal_projection(i) > angle_thres_low)
{
weighted_areas(i) = mesh.its.facet_area(i) * (1.0f - normal_projection(i));
}
}
const size_t sample_nums = 180;
std::vector<Vec3f> cool_dirs;
for (size_t i = 0; i < sample_nums; i++)
{
float angle_deg = i * (360.0 / sample_nums);
float angle_rad = angle_deg * (PI / 180.0);
cool_dirs.push_back(Vec3f{ std::cos(angle_rad), std::sin(angle_rad), 0 });
}
// 3. accumulate the weighted projected overhang area, find the max weighted project area direction
float overhang_projected_max = 0.f;
float overhang_projected_origin = 0.f;
for (auto cool_dir : cool_dirs)
{
float overhang_projected_tmp = 0.f;
for (size_t i = 0; i < nfaces; i++)
{
float cool_dir_projection = face_normals[i].dot(cool_dir);
if (weighted_areas[i] > 0 && cool_dir_projection > 0)
{
overhang_projected_tmp += weighted_areas[i] * cool_dir_projection;
}
}
if (overhang_projected_tmp > overhang_projected_max)
{
overhang_projected_max = overhang_projected_tmp;
best_direction = cool_dir;
}
if (cool_dir.dot(machine_cool_dir) > 0.999)
{
overhang_projected_origin = overhang_projected_tmp;
}
}
// The symmetric model has similar overhang projection at all angles, so Z-axis rotation is unnecessary.
if (std::abs(overhang_projected_origin - overhang_projected_max) < 1.0f)
{
return;
}
// rotate the mesh
Vec3d axis;
double angle;
Matrix3d rotation_matrix;
Geometry::rotation_from_two_vectors(best_direction.cast<double>(), machine_cool_dir.cast<double>(), axis, angle, &rotation_matrix);
mesh.rotate(angle, axis);
}
} // namespace arr
} // namespace Slic3r

View File

@@ -26,10 +26,18 @@ struct OrientMesh {
TriangleMesh mesh; /// The real mesh data
double overhang_angle = 30;
double angle{ 0 };
double angle_vertical{ 0 };
Vec3d axis{ 0,0,1 };
Vec3d axis_vertical{ 0,0,1 };
Vec3d orientation{ 0,0,1 };
Matrix3d rotation_matrix;
Vec3d euler_angles;
Vec3d orientation_vertical{ -1,0,0 };
Matrix3d rotation_matrix = Matrix3d::Identity();
Matrix3d rotation_matrix_vertical = Matrix3d::Identity();
Vec3d euler_angles = {0, 0, 0};
Vec3d euler_angles_vertical = {0, 0, 0};
Vec3d cooling_direction = {0, 0, 0};
bool has_cooling_fan{false};
std::string name;
/// Optional setter function which can store arbitrary data in its closure
@@ -154,6 +162,9 @@ void orient(ModelObject* obj);
void orient(ModelInstance* instance);
// rotate z axis for cooling
void orient_for_cooling(TriangleMesh& mesh, const FanDirection& fan_dir);
}} // namespace Slic3r::orientment
#endif // MODELORIENT_HPP

View File

@@ -1390,7 +1390,7 @@ static std::vector<std::string> s_Preset_printer_options {
"default_print_profile", "inherits",
"silent_mode",
"scan_first_layer", "enable_power_loss_recovery", "wrapping_detection_layers", "wrapping_exclude_area", "machine_load_filament_time", "machine_unload_filament_time", "machine_tool_change_time", "time_cost", "machine_pause_gcode", "template_custom_gcode",
"nozzle_type", "nozzle_hrc","auxiliary_fan", "nozzle_volume","upward_compatible_machine", "z_hop_types", "travel_slope", "retract_lift_enforce","support_chamber_temp_control","support_air_filtration","cooling_filter_enabled","printer_structure","farthest_point_timelapse",
"nozzle_type", "nozzle_hrc","auxiliary_fan", "fan_direction", "nozzle_volume","upward_compatible_machine", "z_hop_types", "travel_slope", "retract_lift_enforce","support_chamber_temp_control","support_air_filtration","cooling_filter_enabled","printer_structure","farthest_point_timelapse",
"best_object_pos", "head_wrap_detect_zone",
"host_type", "print_host", "printhost_apikey", "flashforge_serial_number", "bbl_use_printhost", "printer_agent",
"print_host_webui",

View File

@@ -512,6 +512,14 @@ static t_config_enum_values s_keys_map_NozzleType {
};
CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(NozzleType)
static t_config_enum_values s_keys_map_FanDirection {
{ "undefine", int(FanDirection::fdUndefine) },
{ "left", int(FanDirection::fdLeft) },
{ "right", int(FanDirection::fdRight) },
{ "both", int(FanDirection::fdBoth) }
};
CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(FanDirection)
static t_config_enum_values s_keys_map_PrinterStructure {
{"undefine", int(PrinterStructure::psUndefine)},
{"corexy", int(PrinterStructure::psCoreXY)},
@@ -3997,6 +4005,21 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false));
def = this->add("fan_direction", coEnum);
def->label = L("Fan direction");
def->tooltip = L("Cooling fan direction of the printer");
def->enum_keys_map = &ConfigOptionEnum<FanDirection>::get_enum_values();
def->enum_values.push_back("undefine");
def->enum_values.push_back("left");
def->enum_values.push_back("right");
def->enum_values.push_back("both");
def->enum_labels.push_back(L("Undefined"));
def->enum_labels.push_back(L("Left"));
def->enum_labels.push_back(L("Right"));
def->enum_labels.push_back(L("Both"));
def->mode = comDevelop;
def->set_default_value(new ConfigOptionEnum<FanDirection>(fdUndefine));
def = this->add("fan_speedup_time", coFloat);
// Label is set in Tab.cpp in the Line object.
//def->label = L("Fan speed-up time");

View File

@@ -362,6 +362,13 @@ enum LayerSeq {
flsCustomize
};
enum FanDirection {
fdUndefine = 0,
fdLeft,
fdRight,
fdBoth
};
static std::unordered_map<NozzleType, std::string>NozzleTypeEumnToStr = {
{NozzleType::ntUndefine, "undefine"},
{NozzleType::ntHardenedSteel, "hardened_steel"},
@@ -1494,6 +1501,7 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionEnumsGenericNullable,nozzle_type))
((ConfigOptionInt, nozzle_hrc))
((ConfigOptionBool, auxiliary_fan))
((ConfigOptionEnum<FanDirection>, fan_direction))
((ConfigOptionBool, support_air_filtration))
((ConfigOptionBool, cooling_filter_enabled))
((ConfigOptionEnum<PrinterStructure>,printer_structure))

View File

@@ -38,6 +38,7 @@
#include "slic3r/Utils/FixModelByCgal.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Orient.hpp"
#include "libslic3r/PrintConfig.hpp"
#ifdef __WXMSW__
@@ -2560,6 +2561,15 @@ void ObjectList::load_shape_object(const std::string &type_name)
// Create mesh
BoundingBoxf3 bb;
TriangleMesh mesh = create_mesh(type_name, bb);
// Rotate the largest overhang area toward the part cooling fan so new shapes start in a
// cooling friendly orientation. Skip the plain cube: the pressure advance pattern
// calibration reuses it as an axis-aligned anchor and scales it by its bounding box,
// so its orientation must stay fixed.
const Slic3r::DynamicPrintConfig& full_config = wxGetApp().preset_bundle->full_config();
if (type_name != "Cube" && full_config.has("fan_direction") && full_config.has("auxiliary_fan")) {
FanDirection config_dir = full_config.option<ConfigOptionEnum<FanDirection>>("fan_direction")->value;
orientation::orient_for_cooling(mesh, config_dir);
}
// BBS: remove "Shape" prefix
load_mesh_object(mesh, _(type_name));
wxGetApp().mainframe->update_title();

View File

@@ -229,15 +229,32 @@ orientation::OrientMesh OrientJob::get_orient_mesh(ModelInstance* instance)
auto obj = instance->get_object();
om.name = obj->name;
om.mesh = obj->mesh(); // don't know the difference to obj->raw_mesh(). Both seem OK
const Slic3r::DynamicPrintConfig& config = wxGetApp().preset_bundle->full_config();
if (obj->config.has("support_threshold_angle"))
om.overhang_angle = obj->config.opt_int("support_threshold_angle");
else {
const Slic3r::DynamicPrintConfig& config = wxGetApp().preset_bundle->full_config();
om.overhang_angle = config.opt_int("support_threshold_angle");
}
if (config.has("fan_direction") && config.has("auxiliary_fan")) {
FanDirection config_dir = config.option<ConfigOptionEnum<FanDirection>>("fan_direction")->value;
if (config_dir == FanDirection::fdUndefine || !config.opt_bool("auxiliary_fan")) {
// no part cooling airflow to face, keep the orientation around the z axis unchanged
om.cooling_direction = {0, 0, 0};
} else if (config_dir == FanDirection::fdRight) {
// the part cooling airflow comes from the right side
om.cooling_direction = {1, 0, 0};
om.has_cooling_fan = true;
} else {
// the part cooling airflow comes from the left side, or from both sides
om.cooling_direction = {-1, 0, 0};
om.has_cooling_fan = true;
}
}
om.setter = [instance](const OrientMesh& p) {
instance->rotate(p.rotation_matrix);
instance->rotate(p.rotation_matrix_vertical);
instance->get_object()->invalidate_bounding_box();
instance->get_object()->ensure_on_bed();
};

View File

@@ -4956,6 +4956,7 @@ void TabPrinter::build_fff()
optgroup->append_single_option_line("nozzle_type", "printer_basic_information_accessory#nozzle-type");
optgroup->append_single_option_line("nozzle_hrc", "printer_basic_information_accessory#nozzle-hrc");
optgroup->append_single_option_line("auxiliary_fan", "printer_basic_information_accessory#auxiliary-part-cooling-fan");
optgroup->append_single_option_line("fan_direction");
optgroup->append_single_option_line("support_chamber_temp_control", "printer_basic_information_accessory#support-controlling-chamber-temperature");
optgroup->append_single_option_line("support_air_filtration", "printer_basic_information_accessory#support-air-filtration");
@@ -5892,6 +5893,8 @@ void TabPrinter::toggle_options()
const bool support_parallel_printheads = printer_cfg.opt_bool("support_parallel_printheads");
toggle_line("parallel_printheads_count", support_parallel_printheads);
toggle_line("fan_direction", m_config->opt_bool("auxiliary_fan"));
}