CLI: --ground-face-* / --lay-flat / --center-on-bed orientation primitives

Adds the CLI counterparts to the GUI's lay-flat / face-pick gizmos.
Scripted / CI / AI pipelines can now set orientation without rendering
a wxWidgets frame; today the only way is a GUI round-trip.

New CLI actions (all operate in the mesh-local frame so they compose
with prior --rotate-* / --orient flags):

  --ground-largest-face 1     Auto-detect the largest planar-face
   or  --lay-flat 1           cluster (area-weighted), rotate so its
                              normal points -Z. Covers "this part has
                              one obvious flat side" cases.

  --ground-face-normal NX,NY,NZ    Pick the face whose mesh-local
                                   normal best matches the given
                                   vector; ground it. e.g.
                                   `--ground-face-normal 1,0,0`
                                   stands a part on its +X side.

  --ground-face-point X,Y,Z        Find the triangle containing the
                                   given mesh-local point; ground its
                                   face. Disambiguates when several
                                   faces share a normal (largest
                                   containing triangle wins).

  --center-on-bed 1                Translate so the XY bounding-box
                                   centroid lands at the bed center
                                   (derived from printable_area).

New file `src/slic3r/Utils/MeshOrient.{hpp,cpp}`:
- collect_triangles_object / compute_face_clusters — quantize
  per-triangle normals (0.001, ~0.06°) and area-weighted-average
  within clusters. Same clustering logic used by lay-flat.
- apply_ground_rotation — same math as Selection::flattening_rotate
  in the GUI (Selection.cpp:1432): world-space quaternion from the
  transformed normal to -Z, applied as offset * new_rot * old_no_offset
  on every instance of every object, then a per-instance Z-lift so the
  grounded face lands at exactly 0 (avoids "No layers were detected"
  from FP-error z≈-1e-9).
- ground_face_point uses a top-N cluster search + point-in-triangle
  test in local space; largest-area triangle wins on ambiguity.

Rationale: without these, any CLI pipeline that needs a specific
face on the bed must either encode custom rotation math per part or
break out of the pipeline into the GUI. Both are bad for
reproducibility. The --ground-face-* triple + the largest-face
auto-mode cover essentially every orientation intent expressible
in a slicing wizard.

Scope:
- `src/slic3r/Utils/MeshOrient.{hpp,cpp}` — new, ~420 lines
- `src/slic3r/CMakeLists.txt` — 2-line registration
- `src/libslic3r/PrintConfig.cpp` — 5 new CLIMiscConfigDef entries
- `src/OrcaSlicer.cpp` — 58-line handler block + 1 include

No behaviour change when the flags are absent.
This commit is contained in:
packerlschupfer
2026-08-02 12:43:36 +02:00
parent 6f3ca7d1b9
commit c45a9795e1
5 changed files with 525 additions and 0 deletions
+58
View File
@@ -80,6 +80,7 @@ using namespace nlohmann;
#ifdef WIN32 #ifdef WIN32
#include "dev-utils/BaseException.h" #include "dev-utils/BaseException.h"
#endif #endif
#include "slic3r/Utils/MeshOrient.hpp"
#include "slic3r/GUI/PartPlate.hpp" #include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/BitmapCache.hpp" #include "slic3r/GUI/BitmapCache.hpp"
#include "slic3r/GUI/OpenGLManager.hpp" #include "slic3r/GUI/OpenGLManager.hpp"
@@ -4447,6 +4448,63 @@ int CLI::run(int argc, char **argv)
for (auto &o : model.objects) for (auto &o : model.objects)
// this affects volumes: // this affects volumes:
o->rotate(Geometry::deg2rad(m_config.opt_float(opt_key)), Y); o->rotate(Geometry::deg2rad(m_config.opt_float(opt_key)), Y);
} else if (opt_key == "ground_largest_face" || opt_key == "lay_flat") {
// Find the largest planar face cluster across all volumes and rotate so it
// sits on Z=0. Single flag; the operator passes `1` to enable.
if (m_config.option<ConfigOptionInt>(opt_key)->value != 0) {
for (auto &model : m_models)
Slic3r::MeshOrient::ground_largest_face(model);
}
} else if (opt_key == "ground_face_normal") {
// Rotate so the face whose mesh-local normal best matches NX,NY,NZ sits on Z=0.
const std::string &s = m_config.option<ConfigOptionString>(opt_key)->value;
if (!s.empty()) {
Vec3d n;
if (sscanf(s.c_str(), "%lf,%lf,%lf", &n.x(), &n.y(), &n.z()) != 3) {
BOOST_LOG_TRIVIAL(error) << "--ground-face-normal expects NX,NY,NZ (e.g. 0,0,-1), got: " << s;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
for (auto &model : m_models)
Slic3r::MeshOrient::ground_face_normal(model, n);
}
} else if (opt_key == "ground_face_point") {
// Find triangle containing (X,Y,Z) in mesh-local coords, then ground its face.
const std::string &s = m_config.option<ConfigOptionString>(opt_key)->value;
if (!s.empty()) {
Vec3d p;
if (sscanf(s.c_str(), "%lf,%lf,%lf", &p.x(), &p.y(), &p.z()) != 3) {
BOOST_LOG_TRIVIAL(error) << "--ground-face-point expects X,Y,Z, got: " << s;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
bool any_failed = false;
for (auto &model : m_models)
if (!Slic3r::MeshOrient::ground_face_point(model, p)) any_failed = true;
if (any_failed) {
BOOST_LOG_TRIVIAL(error) << "--ground-face-point: point (" << p.x() << "," << p.y() << "," << p.z()
<< ") is not on the mesh surface. Pick a point ON a face.";
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
} else if (opt_key == "center_on_bed") {
// Translate so bbox XY centroid hits bed center (from printable_area; falls back
// to a Core-One-ish (125,110) footprint if the config didn't carry a bed shape).
if (m_config.option<ConfigOptionInt>(opt_key)->value != 0) {
Vec2d bed_center(125.0, 110.0);
if (auto *area_opt = m_print_config.option<ConfigOptionPoints>("printable_area");
area_opt && area_opt->values.size() >= 4) {
Vec2d lo = area_opt->values[0], hi = area_opt->values[0];
for (const auto &p : area_opt->values) {
lo.x() = std::min(lo.x(), p.x()); lo.y() = std::min(lo.y(), p.y());
hi.x() = std::max(hi.x(), p.x()); hi.y() = std::max(hi.y(), p.y());
}
bed_center = 0.5 * (lo + hi);
}
for (auto &model : m_models)
Slic3r::MeshOrient::center_on_bed(model, bed_center);
}
} else if (opt_key == "scale") { } else if (opt_key == "scale") {
float ratio = m_config.opt_float(opt_key); float ratio = m_config.opt_float(opt_key);
if (ratio <= 0.f) { if (ratio <= 0.f) {
+44
View File
@@ -11994,6 +11994,50 @@ CLITransformConfigDef::CLITransformConfigDef()
def->sidetext = u8"°"; // degrees, don't need translation def->sidetext = u8"°"; // degrees, don't need translation
def->set_default_value(new ConfigOptionFloat(0)); def->set_default_value(new ConfigOptionFloat(0));
// "Ground a face to the bed" CLI primitives — GUI equivalents (lay-flat / face-pick
// gizmos) previously had no CLI counterpart, forcing scripted pipelines to
// round-trip through the GUI to set orientation. All work in the mesh-local
// frame so they compose with prior --rotate-* flags.
def = this->add("ground_largest_face", coInt);
def->label = L("Ground largest face");
def->tooltip = L("Find the largest planar face on the mesh and rotate so it sits "
"on the bed (Z=0). Covers the common 'this part has one obvious "
"orientation' case. 1=on, 0=off. Default 0.");
def->cli_params = "0|1";
def->set_default_value(new ConfigOptionInt(0));
def = this->add("lay_flat", coInt);
def->label = L("Lay flat");
def->tooltip = L("Alias for --ground-largest-face. Matches the GUI's lay-flat "
"terminology. 1=on, 0=off. Default 0.");
def->cli_params = "0|1";
def->set_default_value(new ConfigOptionInt(0));
def = this->add("ground_face_normal", coString);
def->label = L("Ground face normal");
def->tooltip = L("Rotate so the face whose mesh-local normal best matches NX,NY,NZ "
"sits on the bed. Example: --ground-face-normal 0,0,-1 grounds the "
"face already pointing -Z (typically already flat). Use 1,0,0 to "
"stand a part on its +X side. Vector is normalized internally.");
def->cli_params = "NX,NY,NZ";
def->set_default_value(new ConfigOptionString(""));
def = this->add("ground_face_point", coString);
def->label = L("Ground face at point");
def->tooltip = L("Find the triangle containing the given mesh-local point X,Y,Z "
"and rotate so its face sits on the bed. Useful when multiple "
"faces have similar normals — picking by point disambiguates. "
"Coords are mesh-local (post-OrcaSlicer centering).");
def->cli_params = "X,Y,Z";
def->set_default_value(new ConfigOptionString(""));
def = this->add("center_on_bed", coInt);
def->label = L("Center on bed");
def->tooltip = L("Translate the model so its XY bounding-box center lands at the bed "
"center. Useful after --ground-* operations. 1=on, 0=off. Default 0.");
def->cli_params = "0|1";
def->set_default_value(new ConfigOptionInt(0));
def = this->add("scale", coFloat); def = this->add("scale", coFloat);
def->label = L("Scale"); def->label = L("Scale");
def->tooltip = L("Scale the model by a float factor."); def->tooltip = L("Scale the model by a float factor.");
+2
View File
@@ -655,6 +655,8 @@ set(SLIC3R_GUI_SOURCES
Utils/bambu_networking.hpp Utils/bambu_networking.hpp
Utils/Bonjour.cpp Utils/Bonjour.cpp
Utils/Bonjour.hpp Utils/Bonjour.hpp
Utils/MeshOrient.cpp
Utils/MeshOrient.hpp
Utils/CalibUtils.cpp Utils/CalibUtils.cpp
Utils/CalibUtils.hpp Utils/CalibUtils.hpp
Utils/ColorSpaceConvert.cpp Utils/ColorSpaceConvert.cpp
+369
View File
@@ -0,0 +1,369 @@
// MeshOrient.cpp — CLI orientation primitives. See MeshOrient.hpp.
#include "MeshOrient.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Point.hpp"
#include <boost/log/trivial.hpp>
#include <boost/format.hpp>
#include <Eigen/Geometry>
#include <cmath>
#include <map>
#include <unordered_map>
#include <limits>
#include <utility>
namespace Slic3r {
namespace MeshOrient {
namespace {
// Per-triangle data: outward normal (unit) + area (mm²).
struct TriInfo {
Vec3d normal;
double area;
};
// Iterate every triangle of every volume of every object, return their unit
// normals + areas (all in mesh-local coords). Skips degenerate (zero-area)
// triangles. The caller is interested in the surface composition, not which
// triangle came from where — area is the only weight that matters.
std::vector<TriInfo> collect_triangles(const Model &model)
{
std::vector<TriInfo> tris;
for (const ModelObject *mo : model.objects) {
if (!mo) continue;
for (const ModelVolume *mv : mo->volumes) {
if (!mv || !mv->is_model_part()) continue;
const indexed_triangle_set &its = mv->mesh().its;
tris.reserve(tris.size() + its.indices.size());
for (const Vec3i32 &tri : its.indices) {
const Vec3f &a = its.vertices[tri[0]];
const Vec3f &b = its.vertices[tri[1]];
const Vec3f &c = its.vertices[tri[2]];
const Vec3d ab = (b - a).cast<double>();
const Vec3d ac = (c - a).cast<double>();
const Vec3d cross = ab.cross(ac);
const double mag = cross.norm();
if (mag < 1e-12) continue; // degenerate
tris.push_back({ cross / mag, 0.5 * mag });
}
}
}
return tris;
}
// Apply a rotation that maps `target_normal_mesh` (mesh-local, will be
// normalized) to -Z, to every instance of every object. Same math as
// Selection::flattening_rotate (GUI Selection.cpp:1432) — quaternion from
// the world-space transformed normal to -Z, applied after the existing
// instance matrix (preserving offset).
void apply_ground_rotation(Model &model, const Vec3d &target_normal_mesh)
{
const Vec3d n_mesh = target_normal_mesh.normalized();
for (ModelObject *mo : model.objects) {
if (!mo) continue;
for (ModelInstance *inst : mo->instances) {
if (!inst) continue;
const Geometry::Transformation &t = inst->get_transformation();
// Transform the mesh-local normal into world coords via the
// inverse-transpose of the rotation/scale (3x3 block).
const Vec3d tnormal = t.get_matrix().matrix().block(0, 0, 3, 3)
.inverse().transpose() * n_mesh;
const Eigen::Quaterniond q = Eigen::Quaterniond()
.setFromTwoVectors(tnormal.normalized(), -Vec3d::UnitZ());
const Transform3d rotation(q);
// Compose new matrix: offset * new_rotation * old_no_offset.
const Transform3d new_matrix = t.get_offset_matrix()
* rotation
* t.get_matrix_no_offset();
inst->set_transformation(Geometry::Transformation(new_matrix));
}
//ORCA: lift each instance so its grounded face sits exactly at Z=0.
// ModelObject::ensure_on_bed() is a no-op for CLI-loaded instances
// (it skips any instance whose auto_drop flag is false, and CLI
// loaders default that to false). Slicer-chat 2026-06-22 hit this:
// after Quaterniond rotation the grounded face landed at z≈-1e-9
// due to FP, the slicer then rejected the model with the cryptic
// "No layers were detected" error. Apply the lift directly: per
// instance, compute its world-coord bbox min.z and shift the
// offset by -min.z (zero if already on or above bed).
for (size_t i = 0; i < mo->instances.size(); ++i) {
ModelInstance *inst = mo->instances[i];
if (!inst) continue;
const BoundingBoxf3 ib = mo->instance_bounding_box(i, false);
const double min_z = ib.min.z();
if (min_z != 0.0) { // covers below AND above
Vec3d o = inst->get_offset();
o.z() -= min_z;
inst->set_offset(o);
}
}
}
}
} // namespace
bool ground_largest_face(Model &model, std::string *out_chosen_normal)
{
const std::vector<TriInfo> tris = collect_triangles(model);
if (tris.empty()) {
BOOST_LOG_TRIVIAL(error) << "MeshOrient: model has no triangles to ground";
return false;
}
// Cluster triangles by quantized normal direction. Quantize each normal
// component to 0.001 (≈ 0.06° angular precision) — coplanar triangles
// from triangulation share a normal to many decimals, so this groups
// them while keeping distinct face orientations apart.
struct QKey { int x, y, z; };
struct QKeyHash { size_t operator()(const QKey &k) const noexcept {
return (size_t(uint32_t(k.x)) * 73856093u)
^ (size_t(uint32_t(k.y)) * 19349663u)
^ (size_t(uint32_t(k.z)) * 83492791u);
}};
struct QKeyEq { bool operator()(const QKey &a, const QKey &b) const noexcept {
return a.x == b.x && a.y == b.y && a.z == b.z;
}};
auto quantize = [](const Vec3d &n) -> QKey {
return { int(std::lround(n.x() * 1000.0)),
int(std::lround(n.y() * 1000.0)),
int(std::lround(n.z() * 1000.0)) };
};
// Bucket sum-of-area per quantized normal, plus the area-weighted normal
// sum so we can recover a precise representative direction at the end.
struct Bucket { double area = 0.0; Vec3d weighted_normal = Vec3d::Zero(); };
std::unordered_map<QKey, Bucket, QKeyHash, QKeyEq> buckets;
buckets.reserve(tris.size() / 4 + 1);
for (const TriInfo &t : tris) {
Bucket &b = buckets[quantize(t.normal)];
b.area += t.area;
b.weighted_normal += t.area * t.normal;
}
// Pick the bucket with maximum accumulated area.
double max_area = -1.0;
Vec3d best_normal = Vec3d::UnitZ();
for (const auto &kv : buckets) {
if (kv.second.area > max_area) {
max_area = kv.second.area;
best_normal = kv.second.weighted_normal.normalized();
}
}
BOOST_LOG_TRIVIAL(info) << boost::format(
"MeshOrient::ground_largest_face: %1% triangles, %2% distinct normals, "
"largest cluster area=%3$.3f mm² normal=(%4$+.4f, %5$+.4f, %6$+.4f)")
% tris.size() % buckets.size() % max_area
% best_normal.x() % best_normal.y() % best_normal.z();
if (out_chosen_normal)
*out_chosen_normal = (boost::format("(%1$+.4f,%2$+.4f,%3$+.4f)")
% best_normal.x() % best_normal.y() % best_normal.z()).str();
apply_ground_rotation(model, best_normal);
return true;
}
bool ground_face_normal(Model &model, const Vec3d &target_normal)
{
if (target_normal.norm() < 1e-9) {
BOOST_LOG_TRIVIAL(error) << "MeshOrient::ground_face_normal: zero-length target normal";
return false;
}
const Vec3d target = target_normal.normalized();
const std::vector<TriInfo> tris = collect_triangles(model);
if (tris.empty()) {
BOOST_LOG_TRIVIAL(error) << "MeshOrient::ground_face_normal: model has no triangles";
return false;
}
// Of all the triangles whose normal best matches `target`, pick the
// representative as the area-weighted average of the cluster of
// triangles within an angular tolerance of the best hit (0.5° ≈ 1e-4
// in dot-product terms). This handles re-triangulated meshes where the
// matching face was split into many triangles.
double best_dot = -2.0;
for (const TriInfo &t : tris)
best_dot = std::max(best_dot, t.normal.dot(target));
const double tol = 1e-4;
Vec3d weighted_normal = Vec3d::Zero();
double cluster_area = 0.0;
for (const TriInfo &t : tris) {
const double d = t.normal.dot(target);
if (d >= best_dot - tol) {
weighted_normal += t.area * t.normal;
cluster_area += t.area;
}
}
const Vec3d chosen = weighted_normal.normalized();
BOOST_LOG_TRIVIAL(info) << boost::format(
"MeshOrient::ground_face_normal: target=(%1$+.4f,%2$+.4f,%3$+.4f) "
"→ chosen=(%4$+.4f,%5$+.4f,%6$+.4f) cluster_area=%7$.3f mm² "
"best_dot=%8$.6f")
% target.x() % target.y() % target.z()
% chosen.x() % chosen.y() % chosen.z()
% cluster_area % best_dot;
apply_ground_rotation(model, chosen);
return true;
}
bool ground_face_point(Model &model, const Vec3d &point_mesh)
{
// First pass: compute the mesh bbox centroid in mesh-local coords. When
// multiple faces overlap the click point (internal + external surfaces
// of a wall, both sides of an edge), we use this centroid to disambiguate:
// prefer the face whose normal points AWAY from the centroid (the
// "outward-facing" surface — what an operator pointing at the part
// intuitively means by "this face").
BoundingBoxf3 bbox;
bool have_box = false;
for (const ModelObject *mo : model.objects) {
if (!mo) continue;
for (const ModelVolume *mv : mo->volumes) {
if (!mv || !mv->is_model_part()) continue;
for (const Vec3f &v : mv->mesh().its.vertices) {
if (!have_box) { bbox.min = bbox.max = v.cast<double>(); have_box = true; }
else bbox.merge(v.cast<double>());
}
}
}
Vec3d mesh_centroid = Vec3d::Zero();
if (have_box) mesh_centroid = 0.5 * (bbox.min + bbox.max);
// Find every triangle that contains `point_mesh` (barycentric check),
// then pick the most outward-facing one (highest dot product of normal
// with the centroid→point direction). Ties broken by larger area.
Vec3d best_normal = Vec3d::UnitZ();
double best_outward = -2.0;
double best_area = -1.0;
int hits = 0;
Vec3d outward_ref(0.0, 0.0, -1.0);
if ((point_mesh - mesh_centroid).norm() > 1e-9)
outward_ref = (point_mesh - mesh_centroid).normalized();
for (const ModelObject *mo : model.objects) {
if (!mo) continue;
for (const ModelVolume *mv : mo->volumes) {
if (!mv || !mv->is_model_part()) continue;
const indexed_triangle_set &its = mv->mesh().its;
for (const Vec3i32 &tri : its.indices) {
const Vec3d a = its.vertices[tri[0]].cast<double>();
const Vec3d b = its.vertices[tri[1]].cast<double>();
const Vec3d c = its.vertices[tri[2]].cast<double>();
const Vec3d ab = b - a;
const Vec3d ac = c - a;
const Vec3d cross = ab.cross(ac);
const double area2 = cross.norm(); // 2× area
if (area2 < 1e-12) continue;
const Vec3d n = cross / area2;
// Plane distance (point must lie in the triangle's plane).
const Vec3d ap = point_mesh - a;
const double plane_d = std::abs(ap.dot(n));
if (plane_d > 1e-3) continue;
// Barycentrics via projection onto ab/ac basis.
const double d00 = ab.dot(ab);
const double d01 = ab.dot(ac);
const double d11 = ac.dot(ac);
const double d20 = ap.dot(ab);
const double d21 = ap.dot(ac);
const double denom = d00 * d11 - d01 * d01;
if (std::abs(denom) < 1e-12) continue;
const double v = (d11 * d20 - d01 * d21) / denom;
const double w = (d00 * d21 - d01 * d20) / denom;
const double u = 1.0 - v - w;
const double tol = 1e-3;
if (u < -tol || v < -tol || w < -tol) continue;
++hits;
const double outward = n.dot(outward_ref);
const double tri_area = 0.5 * area2;
// Outward direction wins; area is the tiebreaker.
if (outward > best_outward + 1e-6 ||
(std::abs(outward - best_outward) <= 1e-6 && tri_area > best_area)) {
best_outward = outward;
best_area = tri_area;
best_normal = n;
}
}
}
}
if (hits == 0) {
BOOST_LOG_TRIVIAL(error) << boost::format(
"MeshOrient::ground_face_point: no triangle contains point "
"(%1$.3f, %2$.3f, %3$.3f) — give a point ON the mesh surface")
% point_mesh.x() % point_mesh.y() % point_mesh.z();
return false;
}
BOOST_LOG_TRIVIAL(info) << boost::format(
"MeshOrient::ground_face_point: point=(%1$.3f,%2$.3f,%3$.3f) hits=%4% "
"chosen_normal=(%5$+.4f,%6$+.4f,%7$+.4f) area=%8$.3f mm²")
% point_mesh.x() % point_mesh.y() % point_mesh.z()
% hits
% best_normal.x() % best_normal.y() % best_normal.z()
% best_area;
apply_ground_rotation(model, best_normal);
return true;
}
bool center_on_bed(Model &model, const Vec2d &bed_center)
{
// Compute the combined bounding box of every instance in world coords,
// then translate every instance by (bed_center - bbox_xy_centroid).
// Z is left alone — ensure_on_bed sets that.
if (model.objects.empty()) {
BOOST_LOG_TRIVIAL(error) << "MeshOrient::center_on_bed: model is empty";
return false;
}
BoundingBoxf3 combined;
bool have_box = false;
for (const ModelObject *mo : model.objects) {
if (!mo) continue;
for (size_t i = 0; i < mo->instances.size(); ++i) {
const BoundingBoxf3 ib = mo->instance_bounding_box(i);
if (!have_box) { combined = ib; have_box = true; }
else combined.merge(ib);
}
}
if (!have_box) {
BOOST_LOG_TRIVIAL(error) << "MeshOrient::center_on_bed: no instances to center";
return false;
}
const Vec2d centroid_xy(0.5 * (combined.min.x() + combined.max.x()),
0.5 * (combined.min.y() + combined.max.y()));
const Vec3d shift(bed_center.x() - centroid_xy.x(),
bed_center.y() - centroid_xy.y(),
0.0);
for (ModelObject *mo : model.objects) {
if (!mo) continue;
for (ModelInstance *inst : mo->instances) {
if (!inst) continue;
inst->set_offset(inst->get_offset() + shift);
}
}
BOOST_LOG_TRIVIAL(info) << boost::format(
"MeshOrient::center_on_bed: bed=(%1$.1f,%2$.1f) centroid=(%3$.1f,%4$.1f) shift=(%5$+.1f,%6$+.1f)")
% bed_center.x() % bed_center.y()
% centroid_xy.x() % centroid_xy.y()
% shift.x() % shift.y();
return true;
}
} // namespace MeshOrient
} // namespace Slic3r
+52
View File
@@ -0,0 +1,52 @@
// MeshOrient.hpp — CLI orientation primitives.
//
// Implements "ground a face to the bed" operations that the GUI exposes via
// the lay-flat / face-pick gizmos but the CLI was missing. Slicer-chat
// 2026-06-22: auto-orient picks bad orientations for parts with one obvious
// flat face; without these primitives, the operator has to break the
// pipeline and round-trip through the GUI just to set orientation.
//
// All functions operate on Model in-place — they rotate the ModelInstance
// transform of every instance of every object so that the chosen face lands
// on Z=0 (normal pointing -Z). They don't translate; ensure_on_bed in the
// existing CLI pipeline handles the Z-lift afterwards.
//
// Mesh-local normals: the operator's --ground-face-normal vector is
// interpreted in the mesh's *own* coordinate system, NOT in world coords.
// This is robust against prior --rotate-* CLI flags being applied first.
#ifndef slic3r_MeshOrient_hpp_
#define slic3r_MeshOrient_hpp_
#include "libslic3r/Point.hpp"
#include <string>
namespace Slic3r {
class Model;
namespace MeshOrient {
// Find the largest planar face (cluster of coplanar triangles by mesh-local
// normal) across all volumes of all objects in `model`, then rotate every
// instance so that face points to -Z. Returns true on success; false +
// emits a BOOST_LOG error if the mesh is empty.
bool ground_largest_face(Model &model, std::string *out_chosen_normal = nullptr);
// Rotate every instance so the face whose mesh-local normal best matches
// `target_normal` (highest dot product) points to -Z. The target is
// normalized internally; (0,0,0) is rejected.
bool ground_face_normal(Model &model, const Vec3d &target_normal);
// Find the triangle that contains `point_mesh` (in mesh-local coords) and
// ground its face. If multiple triangles contain the point (edge/vertex),
// the triangle with the largest area wins.
bool ground_face_point(Model &model, const Vec3d &point_mesh);
// Translate every instance so the combined model bounding-box centroid sits
// at `bed_center` (XY only; Z is left to ensure_on_bed). Called after
// orientation when --center-on-bed is passed.
bool center_on_bed(Model &model, const Vec2d &bed_center);
} // namespace MeshOrient
} // namespace Slic3r
#endif