From 93c8b3f2b029f09552a31a8a51308a3893088df9 Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:56:46 +0200 Subject: [PATCH] CLI: --ground-* orientation from the Lay on Face planes, and --inspect-mesh (#15073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 c45a9795e1665db0c53a1ccbff9445fac36ea095) * 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. --- src/OrcaSlicer.cpp | 106 +++++++++++ src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/LayOnFace.cpp | 221 +++++++++++++++++++++++ src/libslic3r/LayOnFace.hpp | 48 +++++ src/libslic3r/PrintConfig.cpp | 35 ++++ src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp | 149 ++------------- src/slic3r/Utils/MeshInspect.cpp | 61 +++++++ src/slic3r/Utils/MeshInspect.hpp | 20 ++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_lay_on_face.cpp | 205 +++++++++++++++++++++ 11 files changed, 717 insertions(+), 133 deletions(-) create mode 100644 src/libslic3r/LayOnFace.cpp create mode 100644 src/libslic3r/LayOnFace.hpp create mode 100644 src/slic3r/Utils/MeshInspect.cpp create mode 100644 src/slic3r/Utils/MeshInspect.hpp create mode 100644 tests/libslic3r/test_lay_on_face.cpp diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 47b56bc350..29c81caa41 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -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 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&, const Transform3d&)> pick; + if (opt_key == "ground_largest_face") { + if (m_config.opt_bool(opt_key)) + pick = [](const std::vector& 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& planes, const Transform3d&) { return find_plane_by_normal(planes, v); }; + else + pick = [v](const std::vector& 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 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") { diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 202c317e7f..1d68db497d 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -304,6 +304,8 @@ set(lisbslic3r_sources Layer.cpp Layer.hpp LayerRegion.cpp + LayOnFace.cpp + LayOnFace.hpp libslic3r.cpp libslic3r.h Line.cpp diff --git a/src/libslic3r/LayOnFace.cpp b/src/libslic3r/LayOnFace.cpp new file mode 100644 index 0000000000..1112f9d2fe --- /dev/null +++ b/src/libslic3r/LayOnFace.cpp @@ -0,0 +1,221 @@ +#include "LayOnFace.hpp" + +#include "Geometry.hpp" +#include "Geometry/ConvexHull.hpp" +#include "Model.hpp" +#include "TriangleMesh.hpp" + +#include +#include +#include + +namespace Slic3r { + +std::vector 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 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 face_normals = its_face_normals(ch.its); + const std::vector face_neighbors = its_face_neighbors(ch.its); + std::vector facet_queue(num_of_facets, 0); + std::vector 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()); + + 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(); + + 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 &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 &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 &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 diff --git a/src/libslic3r/LayOnFace.hpp b/src/libslic3r/LayOnFace.hpp new file mode 100644 index 0000000000..1a5e92a15e --- /dev/null +++ b/src/libslic3r/LayOnFace.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "Point.hpp" + +#include + +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 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 &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 &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 &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 diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index c334c1bca1..77aaeb694a 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -11956,6 +11956,13 @@ CLIActionsConfigDef::CLIActionsConfigDef() def->tooltip = L("This outputs the model\u2019s information."); 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->label = L("Export Settings"); 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->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->label = L("Scale"); def->tooltip = L("Scale the model by a float factor."); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 691675a452..ca26b80da2 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -680,6 +680,8 @@ set(SLIC3R_GUI_SOURCES Utils/bambu_networking.hpp Utils/Bonjour.cpp Utils/Bonjour.hpp + Utils/MeshInspect.cpp + Utils/MeshInspect.hpp Utils/CalibUtils.cpp Utils/CalibUtils.hpp Utils/ColorSpaceConvert.cpp diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp index a040d96bd5..e9d8598423 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFlatten.cpp @@ -4,7 +4,7 @@ #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp" -#include "libslic3r/Geometry/ConvexHull.hpp" +#include "libslic3r/LayOnFace.hpp" #include "libslic3r/Model.hpp" #include @@ -45,10 +45,10 @@ void GLGizmoFlatten::data_changed(bool is_serializing) const ModelObject *model_object = nullptr; int instance_id = -1; 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()]; instance_id = selection.get_instance_idx(); - } + } set_flattening_data(model_object, instance_id); } @@ -86,7 +86,7 @@ void GLGizmoFlatten::on_render() GLShaderProgram* shader = wxGetApp().get_shader("flat"); if (shader == nullptr) return; - + shader->start_using(); 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() { const ModelObject* mo = m_c->selection_info()->model_object(); - TriangleMesh ch; - for (const ModelVolume* vol : mo->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(); + const Transform3d &inst_matrix = mo->instances.front()->get_matrix_no_offset(); + // The candidate faces are shared with the CLI --ground-* options, the rest only prepares them for rendering. + std::vector planes = lay_on_face_planes(*mo, inst_matrix); m_planes.clear(); 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. - 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 + // We only keep the 254 largest planes (because of the picking pass limitations): + planes.resize(std::min((int)planes.size(), 254)); - // 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 face_normals = its_face_normals(ch.its); - const std::vector face_neighbors = its_face_neighbors(ch.its); - std::vector facet_queue(num_of_facets, 0); - std::vector 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()); - - 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(); - - 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; - } + for (LayOnFacePlane& plane : planes) { + // The outline is convex and lies in the plane frame, where the plane is horizontal. + Pointf3s& polygon = plane.outline; // 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)); @@ -332,13 +216,12 @@ void GLGizmoFlatten::update_planes() b(2) += 0.1f; // 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: m_volumes_matrices.clear(); m_volumes_types.clear(); diff --git a/src/slic3r/Utils/MeshInspect.cpp b/src/slic3r/Utils/MeshInspect.cpp new file mode 100644 index 0000000000..0a60358278 --- /dev/null +++ b/src/slic3r/Utils/MeshInspect.cpp @@ -0,0 +1,61 @@ +#include "MeshInspect.hpp" + +#include "libslic3r/LayOnFace.hpp" +#include "libslic3r/Model.hpp" + +#include + +#include +#include +#include + +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 &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 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 diff --git a/src/slic3r/Utils/MeshInspect.hpp b/src/slic3r/Utils/MeshInspect.hpp new file mode 100644 index 0000000000..0f53b41c4a --- /dev/null +++ b/src/slic3r/Utils/MeshInspect.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +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 &source_paths, std::ostream &out, size_t max_planes = 8); + +} // namespace MeshInspect +} // namespace Slic3r diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index bc5a0a1e80..b3c74335cc 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests test_triangle_selector.cpp test_meshboolean.cpp test_marchingsquares.cpp + test_lay_on_face.cpp test_model.cpp test_utils.cpp test_timeutils.cpp diff --git a/tests/libslic3r/test_lay_on_face.cpp b/tests/libslic3r/test_lay_on_face.cpp new file mode 100644 index 0000000000..60226a9aab --- /dev/null +++ b/tests/libslic3r/test_lay_on_face.cpp @@ -0,0 +1,205 @@ +#include + +#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()); + 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 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 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 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 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 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().cross( + (its.vertices[face[2]] - its.vertices[face[0]]).cast()); + 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 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 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 +}