CLI: --ground-* orientation from the Lay on Face planes, and --inspect-mesh (#15073)

* 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.

(cherry picked from commit c45a9795e1)

* CLI grounding: choose among the Lay on Face planes, per object

Addresses review:
- Move the geometry of GLGizmoFlatten::update_planes() into
  libslic3r/LayOnFace and use it from the gizmo and the CLI, so the
  --ground-* options pick convex-hull faces per object and instance,
  with part transformations (--rotate-x/y) applied.
- Drop --center-on-bed, the --lay-flat alias and MeshOrient; make
  --ground-largest-face a coBool.
- Parse --ground-face-normal and --ground-face-point strictly. A point
  that only some objects contain grounds those and leaves the others.
- Fold in --inspect-mesh from #14603, reporting the same planes.
- Tests in tests/libslic3r/test_lay_on_face.cpp: bounding boxes before
  and after, rotate then ground, two objects, and a ribbed part whose
  parallel inner faces outsum its base.

* CLI --inspect-mesh, --ground-face-*: reject missing input and empty values

- Without an input file or --load-assemble-list, --inspect-mesh printed
  nothing and exited 0. Reject it up front with CLI_INVALID_PARAMS.
- An explicit empty --ground-face-normal or --ground-face-point was
  silently ignored. Only options given on the command line reach the
  transforms loop, so an empty value now fails the strict parse like any
  other malformed value.
This commit is contained in:
packerlschupfer
2026-09-16 12:56:46 +08:00
committed by GitHub
parent 9321f24959
commit 93c8b3f2b0
11 changed files with 717 additions and 133 deletions
+106
View File
@@ -73,6 +73,7 @@ using namespace nlohmann;
#include "libslic3r/Thread.hpp"
#include "libslic3r/BlacklistedLibraryCheck.hpp"
#include "libslic3r/FlushVolCalc.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Orient.hpp"
#include "libslic3r/PNGReadWrite.hpp"
@@ -85,6 +86,7 @@ using namespace nlohmann;
#ifdef WIN32
#include "dev-utils/BaseException.h"
#endif
#include "slic3r/Utils/MeshInspect.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/BitmapCache.hpp"
#include "slic3r/GUI/OpenGLManager.hpp"
@@ -1418,6 +1420,29 @@ int CLI::run(int argc, char **argv)
if (downward_check_option)
downward_check = downward_check_option->value;
// --inspect-mesh prints its JSON and exits, so any action that does work of its
// own (slicing, exporting) would be skipped without notice. Reject those up front;
// only options that merely tune how the input is loaded may come along.
if (std::find(m_actions.begin(), m_actions.end(), "inspect_mesh") != m_actions.end()) {
static const std::set<std::string> inspect_compatible = { "inspect_mesh", "uptodate", "load_defaultfila", "min_save",
"mtcpp", "mstpp", "no_check", "normative_check", "pipe" };
for (const std::string &action : m_actions) {
if (inspect_compatible.count(action) == 0) {
std::string flag = action;
std::replace(flag.begin(), flag.end(), '_', '-');
boost::nowide::cerr << "--inspect-mesh cannot be combined with --" << flag << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
// Without input there is nothing to inspect; fail rather than print nothing and exit 0.
if (m_input_files.empty() && m_config.opt_string("load_assemble_list").empty()) {
boost::nowide::cerr << "--inspect-mesh needs an input file or --load-assemble-list" << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
// --export-settings - writes its JSON to stdout, so reject every action or transform that may write there
// too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is
// sliced or exported.
@@ -4841,6 +4866,64 @@ int CLI::run(int argc, char **argv)
for (auto &o : model.objects)
// this affects volumes:
o->rotate(Geometry::deg2rad(m_config.opt_float(opt_key)), Y);
} else if (opt_key == "ground_largest_face" || opt_key == "ground_face_normal" || opt_key == "ground_face_point") {
// Each instance is laid on one of its lay-on-face planes, which are computed from the current part
// transformations, so the rotations given before this option are respected. A direction or point is in
// object coordinates, so it names the same face for every instance of an object.
std::function<int(const std::vector<LayOnFacePlane>&, const Transform3d&)> pick;
if (opt_key == "ground_largest_face") {
if (m_config.opt_bool(opt_key))
pick = [](const std::vector<LayOnFacePlane>& planes, const Transform3d&) { return find_largest_plane(planes); };
} else {
// Only options given on the command line reach this loop, so an empty value is malformed input too.
const std::string& value = m_config.opt_string(opt_key);
Vec3d v;
int consumed = 0;
if (sscanf(value.c_str(), "%lf,%lf,%lf%n", &v.x(), &v.y(), &v.z(), &consumed) != 3 || consumed != int(value.size()) ||
!v.allFinite() || (opt_key == "ground_face_normal" && v.norm() < EPSILON)) {
BOOST_LOG_TRIVIAL(error) << boost::format("Invalid params: %1% expects three comma-separated numbers, got \"%2%\"") % opt_key % value;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
if (opt_key == "ground_face_normal")
pick = [v](const std::vector<LayOnFacePlane>& planes, const Transform3d&) { return find_plane_by_normal(planes, v); };
else
pick = [v](const std::vector<LayOnFacePlane>& planes, const Transform3d& inst_matrix) {
return find_plane_at_point(planes, inst_matrix, v, 0.01);
};
}
if (pick) {
size_t laid = 0, missed = 0;
for (auto& model : m_models) {
model.add_default_instances();
for (ModelObject* o : model.objects)
for (size_t i = 0; i < o->instances.size(); ++i) {
const Transform3d inst_matrix = o->instances[i]->get_matrix_no_offset();
const std::vector<LayOnFacePlane> planes = lay_on_face_planes(*o, inst_matrix);
if (planes.empty()) {
// Small or smooth parts (e.g. a sphere) have no face to rest on; the gizmo offers none either.
BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: object %2% has no face large enough to lay on, left as it is") % opt_key % o->name;
continue;
}
const int idx = pick(planes, inst_matrix);
if (idx < 0) {
// Only a point can miss: with several objects it usually belongs to one of them.
BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: no face of object %2% contains the point, left as it is") % opt_key % o->name;
++missed;
continue;
}
BOOST_LOG_TRIVIAL(info) << boost::format("%1%: object %2% instance %3% laid on the %4% mm2 face with normal %5%")
% opt_key % o->name % i % planes[idx].area % planes[idx].normal.transpose();
lay_on_face(*o, i, planes[idx].normal);
++laid;
}
}
if (laid == 0 && missed > 0) {
BOOST_LOG_TRIVIAL(error) << boost::format("Invalid params: %1%: no face of any object contains the point") % opt_key;
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 == "scale") {
float ratio = m_config.opt_float(opt_key);
if (ratio <= 0.f) {
@@ -6000,6 +6083,29 @@ int CLI::run(int argc, char **argv)
model.add_default_instances();
model.print_info();
}
} else if (opt_key == "inspect_mesh") {
// Machine-readable alternative to --info. Registered as an action so it satisfies the
// "needs an action" check and bypasses the GUI fallback, then exits once the JSON is out.
for (Model &model : m_models) {
model.add_default_instances();
Slic3r::MeshInspect::inspect_to_json(model, m_input_files, boost::nowide::cout);
}
boost::nowide::cout.flush();
// Conflicting actions were rejected before loading. Finish like the end of run().
// flush_and_exit() is not usable here: it prints "found error ..." to stdout,
// which would corrupt the JSON.
#if defined(__linux__) || defined(__LINUX__)
if (g_cli_callback_mgr.is_started()) {
PrintBase::SlicingStatus slicing_status{100, "All done, Success"};
cli_status_callback(slicing_status);
}
g_cli_callback_mgr.stop();
#endif
for (Model &m : m_models)
m.remove_backup_path_if_exist();
record_exit_reson(outfile_dir, CLI_SUCCESS, plate_to_slice, cli_errors[CLI_SUCCESS], sliced_info);
boost::nowide::cerr.flush();
return CLI_SUCCESS;
} else if (opt_key == "uptodate") {
//already processed before
} else if (opt_key == "min_save") {