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/Thread.hpp"
#include "libslic3r/BlacklistedLibraryCheck.hpp" #include "libslic3r/BlacklistedLibraryCheck.hpp"
#include "libslic3r/FlushVolCalc.hpp" #include "libslic3r/FlushVolCalc.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Orient.hpp" #include "libslic3r/Orient.hpp"
#include "libslic3r/PNGReadWrite.hpp" #include "libslic3r/PNGReadWrite.hpp"
@@ -85,6 +86,7 @@ using namespace nlohmann;
#ifdef WIN32 #ifdef WIN32
#include "dev-utils/BaseException.h" #include "dev-utils/BaseException.h"
#endif #endif
#include "slic3r/Utils/MeshInspect.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"
@@ -1418,6 +1420,29 @@ int CLI::run(int argc, char **argv)
if (downward_check_option) if (downward_check_option)
downward_check = downward_check_option->value; 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 // --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 // too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is
// sliced or exported. // sliced or exported.
@@ -4841,6 +4866,64 @@ 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 == "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") { } 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) {
@@ -6000,6 +6083,29 @@ int CLI::run(int argc, char **argv)
model.add_default_instances(); model.add_default_instances();
model.print_info(); 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") { } else if (opt_key == "uptodate") {
//already processed before //already processed before
} else if (opt_key == "min_save") { } else if (opt_key == "min_save") {
+2
View File
@@ -304,6 +304,8 @@ set(lisbslic3r_sources
Layer.cpp Layer.cpp
Layer.hpp Layer.hpp
LayerRegion.cpp LayerRegion.cpp
LayOnFace.cpp
LayOnFace.hpp
libslic3r.cpp libslic3r.cpp
libslic3r.h libslic3r.h
Line.cpp Line.cpp
+221
View File
@@ -0,0 +1,221 @@
#include "LayOnFace.hpp"
#include "Geometry.hpp"
#include "Geometry/ConvexHull.hpp"
#include "Model.hpp"
#include "TriangleMesh.hpp"
#include <algorithm>
#include <cmath>
#include <numeric>
namespace Slic3r {
std::vector<LayOnFacePlane> lay_on_face_planes(const ModelObject &object, const Transform3d &inst_matrix)
{
// An object can only rest on its convex hull, so candidate faces are taken from the hull of all model parts.
TriangleMesh ch;
for (const ModelVolume* vol : object.volumes) {
if (vol->type() != ModelVolumeType::MODEL_PART)
continue;
TriangleMesh vol_ch = vol->get_convex_hull();
vol_ch.transform(vol->get_matrix());
ch.merge(vol_ch);
}
ch = ch.convex_hull_3d();
std::vector<LayOnFacePlane> planes;
// Following constants are used for discarding too small polygons.
const float minimal_area = 5.f; // in square mm (world coordinates)
const float minimal_side = 1.f; // mm
const float minimal_angle = 1.f; // degree, initial value was 10, but cause bugs
// Now we'll go through all the facets and append Points of facets sharing the same normal.
// This part is still performed in mesh coordinate system.
const int num_of_facets = ch.facets_count();
const std::vector<Vec3f> face_normals = its_face_normals(ch.its);
const std::vector<Vec3i32> face_neighbors = its_face_neighbors(ch.its);
std::vector<int> facet_queue(num_of_facets, 0);
std::vector<bool> facet_visited(num_of_facets, false);
int facet_queue_cnt = 0;
const stl_normal* normal_ptr = nullptr;
int facet_idx = 0;
while (1) {
// Find next unvisited triangle:
for (; facet_idx < num_of_facets; ++ facet_idx)
if (!facet_visited[facet_idx]) {
facet_queue[facet_queue_cnt ++] = facet_idx;
facet_visited[facet_idx] = true;
normal_ptr = &face_normals[facet_idx];
planes.emplace_back();
break;
}
if (facet_idx == num_of_facets)
break; // Everything was visited already
while (facet_queue_cnt > 0) {
int facet_idx = facet_queue[-- facet_queue_cnt];
const stl_normal& this_normal = face_normals[facet_idx];
if (std::abs(this_normal(0) - (*normal_ptr)(0)) < 0.001 && std::abs(this_normal(1) - (*normal_ptr)(1)) < 0.001 && std::abs(this_normal(2) - (*normal_ptr)(2)) < 0.001) {
const Vec3i32 face = ch.its.indices[facet_idx];
for (int j=0; j<3; ++j)
planes.back().outline.emplace_back(ch.its.vertices[face[j]].cast<double>());
facet_visited[facet_idx] = true;
for (int j = 0; j < 3; ++ j)
if (int neighbor_idx = face_neighbors[facet_idx][j]; neighbor_idx >= 0 && ! facet_visited[neighbor_idx])
facet_queue[facet_queue_cnt ++] = neighbor_idx;
}
}
planes.back().normal = normal_ptr->cast<double>();
Pointf3s& verts = planes.back().outline;
// Now we'll transform all the points into world coordinates, so that the areas, angles and distances
// make real sense.
verts = transform(verts, inst_matrix);
// if this is a just a very small triangle, remove it to speed up further calculations (it would be rejected later anyway):
if (verts.size() == 3 &&
((verts[0] - verts[1]).norm() < minimal_side
|| (verts[0] - verts[2]).norm() < minimal_side
|| (verts[1] - verts[2]).norm() < minimal_side))
planes.pop_back();
}
// Let's prepare transformation of the normal vector from mesh to instance coordinates.
const Matrix3d normal_matrix = inst_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
// Now we'll go through all the polygons, transform the points into xy plane to process them:
for (unsigned int polygon_id=0; polygon_id < planes.size(); ++polygon_id) {
Pointf3s& polygon = planes[polygon_id].outline;
const Vec3d& normal = planes[polygon_id].normal;
// transform the normal according to the instance matrix:
const Vec3d normal_transformed = normal_matrix * normal;
// We are going to rotate about z and y to flatten the plane
Eigen::Quaterniond q;
Transform3d& m = planes[polygon_id].to_plane_frame;
m = Transform3d::Identity();
m.matrix().block(0, 0, 3, 3) = q.setFromTwoVectors(normal_transformed, Vec3d::UnitZ()).toRotationMatrix();
polygon = transform(polygon, m);
// Now to remove the inner points. We'll misuse Geometry::convex_hull for that, but since
// it works in fixed point representation, we will rescale the polygon to avoid overflows.
// And yes, it is a nasty thing to do. Whoever has time is free to refactor.
Vec3d bb_size = BoundingBoxf3(polygon).size();
float sf = std::min(1./bb_size(0), 1./bb_size(1));
Transform3d tr = Geometry::scale_transform({ sf, sf, 1.f });
polygon = transform(polygon, tr);
polygon = Slic3r::Geometry::convex_hull(polygon);
polygon = transform(polygon, tr.inverse());
// Calculate area of the polygons and discard ones that are too small
float& area = planes[polygon_id].area;
area = 0.f;
for (unsigned int i = 0; i < polygon.size(); i++) // Shoelace formula
area += polygon[i](0)*polygon[i + 1 < polygon.size() ? i + 1 : 0](1) - polygon[i + 1 < polygon.size() ? i + 1 : 0](0)*polygon[i](1);
area = 0.5f * std::abs(area);
bool discard = false;
if (area < minimal_area)
discard = true;
else {
// We also check the inner angles and discard polygons with angles smaller than the following threshold
const double angle_threshold = ::cos(minimal_angle * (double)PI / 180.0);
for (unsigned int i = 0; i < polygon.size(); ++i) {
const Vec3d& prec = polygon[(i == 0) ? polygon.size() - 1 : i - 1];
const Vec3d& curr = polygon[i];
const Vec3d& next = polygon[(i == polygon.size() - 1) ? 0 : i + 1];
if ((prec - curr).normalized().dot((next - curr).normalized()) > angle_threshold) {
discard = true;
break;
}
}
}
if (discard) {
planes[polygon_id--] = std::move(planes.back());
planes.pop_back();
continue;
}
const Vec3d centroid = std::accumulate(polygon.begin(), polygon.end(), Vec3d(0.0, 0.0, 0.0)) / double(polygon.size());
planes[polygon_id].center = inst_matrix.inverse() * (m.inverse() * centroid);
}
std::sort(planes.rbegin(), planes.rend(), [](const LayOnFacePlane& a, const LayOnFacePlane& b) { return a.area < b.area; });
return planes;
}
int find_largest_plane(const std::vector<LayOnFacePlane> &planes)
{
// The plane frame maps the instance normal to +Z, so the normal's z in instance coordinates is element (2, 2).
auto downward = [](const LayOnFacePlane &plane) { return -plane.to_plane_frame.linear()(2, 2); };
// Areas are floats from rounded geometry, so faces within 0.1% count as equal.
int best = -1;
for (size_t i = 0; i < planes.size() && planes[i].area >= planes.front().area * (1. - 1e-3); ++i)
if (best < 0 || downward(planes[i]) > downward(planes[best]))
best = int(i);
return best;
}
int find_plane_by_normal(const std::vector<LayOnFacePlane> &planes, const Vec3d &direction)
{
const Vec3d dir = direction.normalized();
int best = -1;
double best_dot = -2.;
for (size_t i = 0; i < planes.size(); ++i)
if (const double dot = planes[i].normal.dot(dir); dot > best_dot) {
best_dot = dot;
best = int(i);
}
return best;
}
int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transform3d &instance_matrix_no_offset,
const Vec3d &point, double tolerance)
{
const Vec3d instance_point = instance_matrix_no_offset * point;
for (size_t i = 0; i < planes.size(); ++i) {
const Pointf3s &outline = planes[i].outline;
if (outline.empty())
continue;
const Vec3d p = planes[i].to_plane_frame * instance_point;
// Facets with slightly different normals are merged into one face, so the outline is not exactly flat.
const double z = std::accumulate(outline.begin(), outline.end(), 0., [](double sum, const Vec3d &v) { return sum + v.z(); }) / double(outline.size());
if (std::abs(p.z() - z) > tolerance)
continue;
// The outline is convex: the point is inside when it is not on both sides of its edges.
bool left = false, right = false;
for (size_t j = 0; j < outline.size(); ++j) {
const Vec2d a = outline[j].head<2>();
const Vec2d edge = outline[(j + 1) % outline.size()].head<2>() - a;
const double len = edge.norm();
if (len < EPSILON)
continue;
const double side = cross2(edge, Vec2d(p.head<2>() - a)) / len;
left |= side > tolerance;
right |= side < -tolerance;
}
if (!(left && right))
return int(i);
}
return -1;
}
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal)
{
ModelInstance &instance = *object.instances[instance_idx];
const Geometry::Transformation &trafo = instance.get_transformation();
// Same rotation as Selection::flattening_rotate(): turn the transformed normal to point down.
const Vec3d tnormal = trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
const Transform3d rotation = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
instance.set_transformation(Geometry::Transformation(trafo.get_offset_matrix() * rotation * trafo.get_matrix_no_offset()));
// Drop this instance only: ensure_on_bed() skips instances without auto_drop and measures the first instance.
object.translate_instance(instance_idx, -object.instance_bounding_box(instance_idx).min.z() * Vec3d::UnitZ());
}
} // namespace Slic3r
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "Point.hpp"
#include <vector>
namespace Slic3r {
class ModelObject;
// A face of an object's convex hull that the object can rest on. These are the faces the
// "Lay on Face" gizmo offers and the ones the CLI --ground-* options choose from.
//
// Frames: "object" coordinates have the volume transformations applied but not the instance
// transformation. "Instance" coordinates additionally have the instance rotation, scale and
// mirror applied, but not its offset.
struct LayOnFacePlane
{
Vec3d normal; // outward unit normal, object coordinates
Vec3d center; // centroid of the outline, object coordinates; on the face's mean plane
float area; // mm², instance coordinates
Pointf3s outline; // convex outline in the plane frame, where the face is horizontal
Transform3d to_plane_frame; // rotation from instance coordinates to the plane frame
};
// Candidate faces of the object's model parts, largest first. The instance transformation
// (without offset) is applied before measuring, so faces too small to rest on are dropped
// by their printed size: under 5 mm², a side under 1 mm, or an inner angle under 1°.
std::vector<LayOnFacePlane> lay_on_face_planes(const ModelObject &object, const Transform3d &instance_matrix_no_offset);
// Index of the largest plane, or -1 if `planes` is empty. Of planes with the same area, such as
// the top and bottom of a box, the one already facing down the most wins, so flat parts stay put.
int find_largest_plane(const std::vector<LayOnFacePlane> &planes);
// Index of the plane whose normal is closest to `direction` (object coordinates),
// or -1 if `planes` is empty.
int find_plane_by_normal(const std::vector<LayOnFacePlane> &planes, const Vec3d &direction);
// Index of the plane whose face contains `point` (object coordinates) within `tolerance` mm, or -1
// if there is none. `instance_matrix_no_offset` is the one the planes were computed with.
int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transform3d &instance_matrix_no_offset,
const Vec3d &point, double tolerance);
// Rotates the instance so that `normal` (object coordinates) points down, the same rotation as
// the gizmo applies, then drops the instance so its lowest point is at z = 0.
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal);
} // namespace Slic3r
+35
View File
@@ -11956,6 +11956,13 @@ CLIActionsConfigDef::CLIActionsConfigDef()
def->tooltip = L("This outputs the model\u2019s information."); def->tooltip = L("This outputs the model\u2019s information.");
def->set_default_value(new ConfigOptionBool(false)); def->set_default_value(new ConfigOptionBool(false));
def = this->add("inspect_mesh", coBool);
def->label = L("Inspect mesh (JSON to stdout)");
def->tooltip = L("Print a JSON summary of each loaded object to stdout, then exit: its bounding boxes and the "
"convex hull faces it can be laid on, with their normals, areas and centers. These are the faces "
"the --ground-* options choose from. Machine-readable alternative to --info.");
def->set_default_value(new ConfigOptionBool(false));
def = this->add("export_settings", coString); def = this->add("export_settings", coString);
def->label = L("Export Settings"); def->label = L("Export Settings");
def->tooltip = L("This exports settings to a file. Use - to write them to stdout."); def->tooltip = L("This exports settings to a file. Use - to write them to stdout.");
@@ -12075,6 +12082,34 @@ 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));
// The --ground-* options choose from the faces the "Lay on Face" gizmo offers. Like the other
// transforms they run in command-line order, so they see the rotations given before them.
def = this->add("ground_largest_face", coBool);
def->label = L("Ground largest face");
def->tooltip = L("Lay each object on the largest face of its convex hull and drop it onto the bed. Of equally large "
"faces, the one already facing down is kept. Objects without a face large enough to rest on are left "
"as they are. Transforms run in command-line order, so rotations given before this option are respected. "
"--orient 1 runs after all transforms and replaces the orientation.");
def->set_default_value(new ConfigOptionBool(false));
def = this->add("ground_face_normal", coString);
def->label = L("Ground face by normal");
def->tooltip = L("Lay each object on the convex hull face whose outward normal is closest to the direction NX,NY,NZ "
"and drop it onto the bed. The direction is in object coordinates, which include the rotations given "
"before this option and match the plate axes unless the input file rotates the object. For example, "
"1,0,0 stands the object on its +X side. --orient 1 runs after all transforms and replaces the orientation.");
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("Lay each object on the convex hull face that contains the point X,Y,Z and drop it onto the bed. "
"The point is in object coordinates, which include the rotations given before this option; "
"--inspect-mesh reports face centers in them. Objects without such a face are left as they are, and "
"the run fails if no object has one. --orient 1 runs after all transforms and replaces the orientation.");
def->cli_params = "X,Y,Z";
def->set_default_value(new ConfigOptionString(""));
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
@@ -680,6 +680,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/MeshInspect.cpp
Utils/MeshInspect.hpp
Utils/CalibUtils.cpp Utils/CalibUtils.cpp
Utils/CalibUtils.hpp Utils/CalibUtils.hpp
Utils/ColorSpaceConvert.cpp Utils/ColorSpaceConvert.cpp
+16 -133
View File
@@ -4,7 +4,7 @@
#include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Plater.hpp"
#include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp" #include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp" #include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Model.hpp" #include "libslic3r/Model.hpp"
#include <numeric> #include <numeric>
@@ -45,10 +45,10 @@ void GLGizmoFlatten::data_changed(bool is_serializing)
const ModelObject *model_object = nullptr; const ModelObject *model_object = nullptr;
int instance_id = -1; int instance_id = -1;
if (selection.is_single_full_instance() || if (selection.is_single_full_instance() ||
selection.is_from_single_object() ) { selection.is_from_single_object() ) {
model_object = selection.get_model()->objects[selection.get_object_idx()]; model_object = selection.get_model()->objects[selection.get_object_idx()];
instance_id = selection.get_instance_idx(); instance_id = selection.get_instance_idx();
} }
set_flattening_data(model_object, instance_id); set_flattening_data(model_object, instance_id);
} }
@@ -86,7 +86,7 @@ void GLGizmoFlatten::on_render()
GLShaderProgram* shader = wxGetApp().get_shader("flat"); GLShaderProgram* shader = wxGetApp().get_shader("flat");
if (shader == nullptr) if (shader == nullptr)
return; return;
shader->start_using(); shader->start_using();
glsafe(::glClear(GL_DEPTH_BUFFER_BIT)); glsafe(::glClear(GL_DEPTH_BUFFER_BIT));
@@ -152,134 +152,18 @@ void GLGizmoFlatten::set_flattening_data(const ModelObject* model_object, int in
void GLGizmoFlatten::update_planes() void GLGizmoFlatten::update_planes()
{ {
const ModelObject* mo = m_c->selection_info()->model_object(); const ModelObject* mo = m_c->selection_info()->model_object();
TriangleMesh ch; const Transform3d &inst_matrix = mo->instances.front()->get_matrix_no_offset();
for (const ModelVolume* vol : mo->volumes) { // The candidate faces are shared with the CLI --ground-* options, the rest only prepares them for rendering.
if (vol->type() != ModelVolumeType::MODEL_PART) std::vector<LayOnFacePlane> planes = lay_on_face_planes(*mo, inst_matrix);
continue;
TriangleMesh vol_ch = vol->get_convex_hull();
vol_ch.transform(vol->get_matrix());
ch.merge(vol_ch);
}
ch = ch.convex_hull_3d();
m_planes.clear(); m_planes.clear();
on_unregister_raycasters_for_picking(); on_unregister_raycasters_for_picking();
const Transform3d &inst_matrix = mo->instances.front()->get_matrix_no_offset();
// Following constants are used for discarding too small polygons. // We only keep the 254 largest planes (because of the picking pass limitations):
const float minimal_area = 5.f; // in square mm (world coordinates) planes.resize(std::min((int)planes.size(), 254));
const float minimal_side = 1.f; // mm
const float minimal_angle = 1.f; // degree, initial value was 10, but cause bugs
// Now we'll go through all the facets and append Points of facets sharing the same normal. for (LayOnFacePlane& plane : planes) {
// This part is still performed in mesh coordinate system. // The outline is convex and lies in the plane frame, where the plane is horizontal.
const int num_of_facets = ch.facets_count(); Pointf3s& polygon = plane.outline;
const std::vector<Vec3f> face_normals = its_face_normals(ch.its);
const std::vector<Vec3i32> face_neighbors = its_face_neighbors(ch.its);
std::vector<int> facet_queue(num_of_facets, 0);
std::vector<bool> facet_visited(num_of_facets, false);
int facet_queue_cnt = 0;
const stl_normal* normal_ptr = nullptr;
int facet_idx = 0;
while (1) {
// Find next unvisited triangle:
for (; facet_idx < num_of_facets; ++ facet_idx)
if (!facet_visited[facet_idx]) {
facet_queue[facet_queue_cnt ++] = facet_idx;
facet_visited[facet_idx] = true;
normal_ptr = &face_normals[facet_idx];
m_planes.emplace_back();
break;
}
if (facet_idx == num_of_facets)
break; // Everything was visited already
while (facet_queue_cnt > 0) {
int facet_idx = facet_queue[-- facet_queue_cnt];
const stl_normal& this_normal = face_normals[facet_idx];
if (std::abs(this_normal(0) - (*normal_ptr)(0)) < 0.001 && std::abs(this_normal(1) - (*normal_ptr)(1)) < 0.001 && std::abs(this_normal(2) - (*normal_ptr)(2)) < 0.001) {
const Vec3i32 face = ch.its.indices[facet_idx];
for (int j=0; j<3; ++j)
m_planes.back().vertices.emplace_back(ch.its.vertices[face[j]].cast<double>());
facet_visited[facet_idx] = true;
for (int j = 0; j < 3; ++ j)
if (int neighbor_idx = face_neighbors[facet_idx][j]; neighbor_idx >= 0 && ! facet_visited[neighbor_idx])
facet_queue[facet_queue_cnt ++] = neighbor_idx;
}
}
m_planes.back().normal = normal_ptr->cast<double>();
Pointf3s& verts = m_planes.back().vertices;
// Now we'll transform all the points into world coordinates, so that the areas, angles and distances
// make real sense.
verts = transform(verts, inst_matrix);
// if this is a just a very small triangle, remove it to speed up further calculations (it would be rejected later anyway):
if (verts.size() == 3 &&
((verts[0] - verts[1]).norm() < minimal_side
|| (verts[0] - verts[2]).norm() < minimal_side
|| (verts[1] - verts[2]).norm() < minimal_side))
m_planes.pop_back();
}
// Let's prepare transformation of the normal vector from mesh to instance coordinates.
const Matrix3d normal_matrix = inst_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
// Now we'll go through all the polygons, transform the points into xy plane to process them:
for (unsigned int polygon_id=0; polygon_id < m_planes.size(); ++polygon_id) {
Pointf3s& polygon = m_planes[polygon_id].vertices;
const Vec3d& normal = m_planes[polygon_id].normal;
// transform the normal according to the instance matrix:
const Vec3d normal_transformed = normal_matrix * normal;
// We are going to rotate about z and y to flatten the plane
Eigen::Quaterniond q;
Transform3d m = Transform3d::Identity();
m.matrix().block(0, 0, 3, 3) = q.setFromTwoVectors(normal_transformed, Vec3d::UnitZ()).toRotationMatrix();
polygon = transform(polygon, m);
// Now to remove the inner points. We'll misuse Geometry::convex_hull for that, but since
// it works in fixed point representation, we will rescale the polygon to avoid overflows.
// And yes, it is a nasty thing to do. Whoever has time is free to refactor.
Vec3d bb_size = BoundingBoxf3(polygon).size();
float sf = std::min(1./bb_size(0), 1./bb_size(1));
Transform3d tr = Geometry::scale_transform({ sf, sf, 1.f });
polygon = transform(polygon, tr);
polygon = Slic3r::Geometry::convex_hull(polygon);
polygon = transform(polygon, tr.inverse());
// Calculate area of the polygons and discard ones that are too small
float& area = m_planes[polygon_id].area;
area = 0.f;
for (unsigned int i = 0; i < polygon.size(); i++) // Shoelace formula
area += polygon[i](0)*polygon[i + 1 < polygon.size() ? i + 1 : 0](1) - polygon[i + 1 < polygon.size() ? i + 1 : 0](0)*polygon[i](1);
area = 0.5f * std::abs(area);
bool discard = false;
if (area < minimal_area)
discard = true;
else {
// We also check the inner angles and discard polygons with angles smaller than the following threshold
const double angle_threshold = ::cos(minimal_angle * (double)PI / 180.0);
for (unsigned int i = 0; i < polygon.size(); ++i) {
const Vec3d& prec = polygon[(i == 0) ? polygon.size() - 1 : i - 1];
const Vec3d& curr = polygon[i];
const Vec3d& next = polygon[(i == polygon.size() - 1) ? 0 : i + 1];
if ((prec - curr).normalized().dot((next - curr).normalized()) > angle_threshold) {
discard = true;
break;
}
}
}
if (discard) {
m_planes[polygon_id--] = std::move(m_planes.back());
m_planes.pop_back();
continue;
}
// We will shrink the polygon a little bit so it does not touch the object edges: // We will shrink the polygon a little bit so it does not touch the object edges:
Vec3d centroid = std::accumulate(polygon.begin(), polygon.end(), Vec3d(0.0, 0.0, 0.0)); Vec3d centroid = std::accumulate(polygon.begin(), polygon.end(), Vec3d(0.0, 0.0, 0.0));
@@ -332,13 +216,12 @@ void GLGizmoFlatten::update_planes()
b(2) += 0.1f; b(2) += 0.1f;
// Transform back to 3D (and also back to mesh coordinates) // Transform back to 3D (and also back to mesh coordinates)
polygon = transform(polygon, inst_matrix.inverse() * m.inverse()); m_planes.emplace_back();
m_planes.back().normal = plane.normal;
m_planes.back().area = plane.area;
m_planes.back().vertices = transform(polygon, inst_matrix.inverse() * plane.to_plane_frame.inverse());
} }
// We'll sort the planes by area and only keep the 254 largest ones (because of the picking pass limitations):
std::sort(m_planes.rbegin(), m_planes.rend(), [](const PlaneData& a, const PlaneData& b) { return a.area < b.area; });
m_planes.resize(std::min((int)m_planes.size(), 254));
// Planes are finished - let's save what we calculated it from: // Planes are finished - let's save what we calculated it from:
m_volumes_matrices.clear(); m_volumes_matrices.clear();
m_volumes_types.clear(); m_volumes_types.clear();
+61
View File
@@ -0,0 +1,61 @@
#include "MeshInspect.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Model.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <ostream>
namespace Slic3r {
namespace MeshInspect {
using json = nlohmann::json;
static json to_json(const Vec3d &v) { return json::array({ v.x(), v.y(), v.z() }); }
static json to_json(const BoundingBoxf3 &bb)
{
return { { "min", to_json(bb.min) }, { "max", to_json(bb.max) }, { "size", to_json(bb.size()) } };
}
void inspect_to_json(const Model &model, const std::vector<std::string> &source_paths, std::ostream &out, size_t max_planes)
{
json objects = json::array();
for (const ModelObject *mo : model.objects) {
json obj = { { "name", mo->name },
{ "triangle_count", mo->facets_count() },
{ "instance_count", mo->instances.size() },
{ "bbox_object", to_json(mo->raw_mesh_bounding_box()) } };
if (!mo->instances.empty()) {
const std::vector<LayOnFacePlane> planes = lay_on_face_planes(*mo, mo->instances.front()->get_matrix_no_offset());
json planes_json = json::array();
for (size_t i = 0; i < std::min(planes.size(), max_planes); ++i)
planes_json.push_back({ { "normal", to_json(planes[i].normal) },
{ "area_mm2", std::round(double(planes[i].area) * 1000.) / 1000. },
{ "center", to_json(planes[i].center) } });
obj["bbox_world"] = to_json(mo->instance_bounding_box(0));
obj["instance_offset"] = to_json(mo->instances.front()->get_offset());
obj["plane_count"] = planes.size();
obj["planes"] = std::move(planes_json);
}
objects.push_back(std::move(obj));
}
const json root = {
{ "sources", source_paths },
{ "note", "Lengths in mm. bbox_object and the plane normals and centers are in object coordinates: the parts as "
"currently transformed, without the instance transformation. --ground-face-normal and "
"--ground-face-point take values in these coordinates. area_mm2 uses instance 0's scale, "
"bbox_world is instance 0 on the plate." },
{ "objects", std::move(objects) },
};
// Object names and file paths are arbitrary bytes, and dump() throws on invalid UTF-8 by default.
// Replace such sequences with U+FFFD so the output is always valid JSON.
out << root.dump(2, ' ', false, json::error_handler_t::replace) << std::endl;
}
} // namespace MeshInspect
} // namespace Slic3r
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <iosfwd>
#include <string>
#include <vector>
namespace Slic3r {
class Model;
namespace MeshInspect {
// Writes the --inspect-mesh JSON for `model` to `out`: per object its bounding boxes and the faces
// it can be laid on, taken from lay_on_face_planes() so they are the faces the --ground-* options
// choose from, in the frame those options take. At most `max_planes` faces are listed per object,
// largest first. `source_paths` lists every input file; the CLI merges them into one model.
void inspect_to_json(const Model &model, const std::vector<std::string> &source_paths, std::ostream &out, size_t max_planes = 8);
} // namespace MeshInspect
} // namespace Slic3r
+1
View File
@@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests
test_triangle_selector.cpp test_triangle_selector.cpp
test_meshboolean.cpp test_meshboolean.cpp
test_marchingsquares.cpp test_marchingsquares.cpp
test_lay_on_face.cpp
test_model.cpp test_model.cpp
test_utils.cpp test_utils.cpp
test_timeutils.cpp test_timeutils.cpp
+205
View File
@@ -0,0 +1,205 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/LayOnFace.hpp"
#include "libslic3r/Model.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
// Adds a box part spanning `origin` to `origin + size`, in object coordinates.
void add_box(ModelObject &object, const Vec3d &size, const Vec3d &origin = Vec3d::Zero())
{
TriangleMesh mesh = make_cube(size.x(), size.y(), size.z());
mesh.translate(origin.cast<float>());
object.add_volume(std::move(mesh), ModelVolumeType::MODEL_PART, false);
}
ModelObject &add_box_object(Model &model, const Vec3d &size)
{
ModelObject *object = model.add_object();
add_box(*object, size);
object->add_instance();
return *object;
}
// A 30 x 30 x 2 plate with three 1 mm thick, 20 mm tall ribs along Y. The rib sides facing -X add up
// to more area than the plate's bottom, but only the bottom is a face of the convex hull.
ModelObject &add_ribbed_plate(Model &model)
{
ModelObject *object = model.add_object();
add_box(*object, { 30, 30, 2 });
for (double x : { 5., 14.5, 24. })
add_box(*object, { 1, 30, 20 }, { x, 0, 2 });
object->add_instance();
return *object;
}
std::vector<LayOnFacePlane> instance_planes(const ModelObject &object)
{
return lay_on_face_planes(object, object.instances.front()->get_matrix_no_offset());
}
void lay_on_largest_face(ModelObject &object)
{
const std::vector<LayOnFacePlane> planes = instance_planes(object);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
lay_on_face(object, 0, planes[idx].normal);
}
void check_size(const ModelObject &object, const Vec3d &expected)
{
const Vec3d size = object.instance_bounding_box(0).size();
CHECK_THAT(size.x(), WithinAbs(expected.x(), 1e-3));
CHECK_THAT(size.y(), WithinAbs(expected.y(), 1e-3));
CHECK_THAT(size.z(), WithinAbs(expected.z(), 1e-3));
}
void check_on_bed(const ModelObject &object) { CHECK_THAT(object.instance_bounding_box(0).min.z(), WithinAbs(0., 1e-3)); }
} // namespace
TEST_CASE("A tilted box is laid on its largest face and dropped onto the bed", "[LayOnFace]")
{
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 }); // the 40 x 20 faces are the largest
box.instances.front()->set_rotation({ 0.3, 0.5, 0.2 });
box.instances.front()->set_offset({ 0, 0, 50 });
REQUIRE(box.instance_bounding_box(0).size().z() > 11.);
const std::vector<LayOnFacePlane> planes = instance_planes(box);
REQUIRE(planes.size() == 6);
CHECK_THAT(planes.front().area, WithinAbs(40. * 20., 1e-2));
lay_on_largest_face(box);
CHECK_THAT(box.instance_bounding_box(0).size().z(), WithinAbs(10., 1e-3));
check_on_bed(box);
}
TEST_CASE("A box lying on one of its equally large faces is not flipped", "[LayOnFace]")
{
// A half turn about X puts the other large face down, so the two cases expect different faces
// and neither can pass on the order in which the hull lists them.
const double rotation_x = GENERATE(0., PI);
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 }); // the bottom and top are both 40 x 20
box.instances.front()->set_rotation({ rotation_x, 0, 0 });
const Transform3d before = box.instances.front()->get_matrix_no_offset();
const std::vector<LayOnFacePlane> planes = instance_planes(box);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
// The face down on the plate is the object's -Z face, or its +Z face after the half turn.
CHECK_THAT(planes[idx].normal.z(), WithinAbs(rotation_x == 0. ? -1. : 1., 1e-6));
lay_on_face(box, 0, planes[idx].normal);
CHECK(box.instances.front()->get_matrix_no_offset().isApprox(before, 1e-9));
}
TEST_CASE("Faces are chosen from the orientation left by an earlier part rotation", "[LayOnFace]")
{
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 });
box.rotate(PI / 2., X); // what --rotate-x 90 does: rotates the parts, not the instance
check_size(box, { 40, 10, 20 });
SECTION("the largest face") {
lay_on_largest_face(box);
check_size(box, { 40, 20, 10 });
check_on_bed(box);
}
SECTION("the face pointing along +X") {
const std::vector<LayOnFacePlane> planes = instance_planes(box);
const int idx = find_plane_by_normal(planes, { 1, 0, 0 });
REQUIRE(idx >= 0);
CHECK_THAT(planes[idx].normal.x(), WithinAbs(1., 1e-6));
lay_on_face(box, 0, planes[idx].normal);
check_size(box, { 20, 10, 40 });
check_on_bed(box);
}
}
TEST_CASE("Objects are laid on their own faces independently", "[LayOnFace]")
{
Model model;
// Standing on end through its instance rotation.
ModelObject &standing = add_box_object(model, { 40, 20, 10 });
standing.instances.front()->set_rotation({ 0, PI / 2., 0 });
// Standing on edge through a part rotation, lifted above the bed.
ModelObject &on_edge = add_box_object(model, { 30, 20, 5 });
on_edge.rotate(PI / 2., X);
on_edge.instances.front()->set_offset({ 100, 0, 30 });
check_size(standing, { 10, 20, 40 });
check_size(on_edge, { 30, 5, 20 });
for (ModelObject *object : model.objects)
lay_on_largest_face(*object);
check_size(standing, { 40, 20, 10 });
check_on_bed(standing);
check_size(on_edge, { 30, 20, 5 });
check_on_bed(on_edge);
}
TEST_CASE("A part rests on its largest hull face even when parallel inner faces add up to more area", "[LayOnFace]")
{
Model model;
ModelObject &plate = add_ribbed_plate(model);
double area_facing_minus_x = 0.;
for (const ModelVolume *volume : plate.volumes) {
const indexed_triangle_set &its = volume->mesh().its;
for (const Vec3i32 &face : its.indices) {
const Vec3d cross = (its.vertices[face[1]] - its.vertices[face[0]]).cast<double>().cross(
(its.vertices[face[2]] - its.vertices[face[0]]).cast<double>());
if (cross.normalized().x() < -0.999)
area_facing_minus_x += 0.5 * cross.norm();
}
}
// Summing triangle area per normal would pick a rib side over the 900 mm² bottom.
REQUIRE(area_facing_minus_x > 30. * 30.);
plate.instances.front()->set_rotation({ 0, PI / 2., 0 }); // stand the plate on its side
check_size(plate, { 22, 30, 30 });
const std::vector<LayOnFacePlane> planes = instance_planes(plate);
const int idx = find_largest_plane(planes);
REQUIRE(idx >= 0);
CHECK_THAT(planes[idx].area, WithinAbs(30. * 30., 1e-2));
CHECK_THAT(planes[idx].normal.z(), WithinAbs(-1., 1e-6));
lay_on_face(plate, 0, planes[idx].normal);
check_size(plate, { 30, 30, 22 });
check_on_bed(plate);
}
TEST_CASE("Faces are selected in object coordinates whatever the instance rotation", "[LayOnFace]")
{
Model model;
ModelObject &plate = add_ribbed_plate(model);
plate.instances.front()->set_rotation({ 0, 0, PI / 2. });
const Transform3d instance_matrix = plate.instances.front()->get_matrix_no_offset();
const std::vector<LayOnFacePlane> planes = lay_on_face_planes(plate, instance_matrix);
REQUIRE_FALSE(planes.empty());
// Every face center, as --inspect-mesh reports it, selects its own face.
for (size_t i = 0; i < planes.size(); ++i)
CHECK(find_plane_at_point(planes, instance_matrix, planes[i].center, 0.01) == int(i));
const int bottom = find_plane_at_point(planes, instance_matrix, { 15, 15, 0 }, 0.01);
REQUIRE(bottom >= 0);
CHECK_THAT(planes[bottom].normal.z(), WithinAbs(-1., 1e-6));
CHECK(find_plane_by_normal(planes, { 0, 0, -1 }) == bottom);
// Above the bottom plane, and on a rib side that lies inside the hull.
CHECK(find_plane_at_point(planes, instance_matrix, { 15, 15, 0.5 }, 0.01) == -1);
CHECK(find_plane_at_point(planes, instance_matrix, { 14.5, 15, 12 }, 0.01) == -1);
}
TEST_CASE("A part too small to rest on offers no faces", "[LayOnFace]")
{
Model model;
CHECK(instance_planes(add_box_object(model, { 2, 2, 2 })).empty()); // every face is 4 mm², under the 5 mm² minimum
}