Compare commits

..
Author SHA1 Message Date
Lam Wei Lun c71e456b95 Scale to Fit Print Volume: fit the area every nozzle reaches
Each nozzle reaches only part of the bed on a multi-nozzle printer (H2D:
left 0-325, right 25-350 in X), but the command fitted the whole 350 mm
bed. A model that filled the bed in X therefore landed outside both
nozzles' printable range, and right after the user asked to fit it the
plater warned "The position or size of the model exceeds the right
nozzle's printable range".

Fit the volume every nozzle shares instead -- the same region Arrange
already places items in -- and centre on it rather than on the bed.
2026-10-20 19:09:01 +08:00
Lam Wei Lun f048ede640 Lay on Face: flip a face pointing straight up about X, not an arbitrary axis
Laying a face that already points up down needs a half turn, and every
horizontal axis does the job. setFromTwoVectors() resolved the choice through an
SVD whose null space is the whole XY plane, so the axis followed rounding noise:
the object landed at an arbitrary angle in the plate plane, and a part that fit
the plate no longer did. Turn such a face over about X, which keeps the
footprint, and share the rotation between the gizmo and the --ground-* options
instead of duplicating it.
2026-10-20 18:24:23 +08:00
Lam Wei Lun 07fa713f0c Scale to Fit Print Volume: centre on the active plate
The command centred the selection on the bed at the origin, so scaling a model
that sat on plate 2 moved it back onto plate 1. Every plate shares the bed size,
so only the centre differs: pass the active plate's centre and, for a circular
bed, shift the print circle by it.
2026-10-20 18:24:20 +08:00
Lam Wei Lun 4c85d0e80a Arrange: place items that fill the printable area, and leave plates alone on failure
An item whose outline fits the printable area could still never be placed: the
arrange bed is inset by bed_shrink (1 mm per side) and the item is inflated by
its brim width, and the clamp meant to relax that only kicked in with more than
5 mm to spare. An item scaled to fit the print volume was therefore rejected
every time. Give the bed margin back where the largest selected item needs it,
never below the skirt distance, and cap the inflation at the printable area.

When an item comes back UNARRANGED, the GUI parked it on a virtual bed that was
never created, moving it a whole plate stride off the plate. Keep the instance
where it is instead, and when nothing could be placed at all, skip the plate
rebuild so a too large item cannot be re-homed to another plate.

Arrange no longer recycles plates: it must not delete a plate the user created,
even when the arrange leaves it empty.
2026-10-20 18:23:29 +08:00
15 changed files with 264 additions and 259 deletions

No files matched your search

+3 -25
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u )
export BUILD_TARGET="universal"
;;
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
h ) echo "Usage: ./build_release_macos.sh [-d]"
echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only"
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0
;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;;
esac
done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults
if [ -z "$ARCH" ]; then
@@ -231,8 +211,7 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target deps
)
@@ -335,8 +314,7 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
)
+25 -9
View File
@@ -122,8 +122,8 @@ void update_arrange_params(ArrangeParams& params, const DynamicPrintConfig* prin
void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPrintConfig* print_cfg, ArrangeParams& params) {
// do not inflate brim_width. Objects are allowed to have overlapped brim.
Points bedpts = get_shrink_bedpts(print_cfg, params);
BoundingBox bedbb = Polygon(bedpts).bounding_box();
// A brim reaching the printable area's edge is fine, so measure against the full bed.
BoundingBox bedbb = Polygon(get_bed_shape(*print_cfg)).bounding_box();
// set obj distance for auto seq_print
if (params.is_seq_print) {
bool all_objects_are_short = std::all_of(selected.begin(), selected.end(), [&](ArrangePolygon& ap) { return ap.height < params.nozzle_height; });
@@ -144,14 +144,30 @@ void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPri
// 3. otherwise, use each object's own brim width
ap.inflation = params.min_obj_distance != 0 ? params.min_obj_distance / 2 :
plate_has_tree_support ? scaled(brim_max / 2) : scaled(ap.brim_width);
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x() - 5;
auto diffy = bedbb.size().y() - apbb.size().y() - 5;
if (diffx > 0 && diffy > 0) {
auto min_diff = std::min(diffx, diffy);
ap.inflation = std::min(min_diff / 2, ap.inflation);
}
if (params.min_obj_distance != 0)
return; // an explicit object distance is the user's call, not ours to shrink
// An item cannot be larger than the printable area, inflation included. Inflating
// an almost bed filling item would leave it too big to ever be placed.
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x();
auto diffy = bedbb.size().y() - apbb.size().y();
ap.inflation = std::max<coord_t>(0, std::min({ ap.inflation, diffx / 2, diffy / 2 }));
});
// Give the bed margin, which the arrange bed keeps from the printable area, back
// where the largest item needs it. The skirt keeps its room: pushing a skirt off
// the bed is worse than saying the item does not fit.
if (!selected.empty()) {
coord_t max_width = 0, max_height = 0;
for (const ArrangePolygon &ap : selected) {
BoundingBox apbb = ap.poly.contour.bounding_box();
max_width = std::max(max_width, apbb.size().x() + 2 * ap.inflation);
max_height = std::max(max_height, apbb.size().y() + 2 * ap.inflation);
}
auto margin = [](coord_t bed, coord_t item) { return std::max(0., unscale<double>(bed - item) / 2.); };
params.bed_shrink_x = std::min<double>(params.bed_shrink_x, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().x(), max_width)));
params.bed_shrink_y = std::min<double>(params.bed_shrink_y, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().y(), max_height)));
}
}
void update_unselected_items_inflation(ArrangePolygons& unselected, const DynamicPrintConfig* print_cfg, const ArrangeParams& params)
+19 -3
View File
@@ -213,13 +213,29 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
return -1;
}
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset)
{
// Normal in instance coordinates: a normal transforms by the inverse transpose.
const Vec3d tnormal = (instance_matrix_no_offset.matrix().block(0, 0, 3, 3).inverse().transpose() * normal).normalized();
const Vec3d down = -Vec3d::UnitZ();
// A face already pointing straight up needs a half turn, and every horizontal axis does
// that equally well. setFromTwoVectors() resolves the choice through an SVD whose null
// space is the whole XY plane, so the axis follows rounding noise and the object lands at
// an arbitrary angle in the plate plane. Turn it over about X instead, which keeps the
// footprint the object already had.
if (tnormal.dot(down) < -1. + 1e-9)
return Transform3d(Eigen::AngleAxisd(PI, Vec3d::UnitX()));
return Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, down));
}
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()));
// Same rotation as Selection::flattening_rotate(), which the gizmo uses.
const Transform3d rotation = lay_on_face_rotation(normal, trafo.get_matrix_no_offset());
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());
+6
View File
@@ -46,4 +46,10 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
// 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);
// The rotation that turns `normal` (object coordinates) to point down, given the instance's
// rotation, scale and mirror. A face that already points straight up is turned over about X:
// the axis setFromTwoVectors() picks there is arbitrary, and it lands the object at a random
// angle in the plate plane.
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset);
} // namespace Slic3r
+15 -3
View File
@@ -771,6 +771,17 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
if (canceled || eptr)
return;
// Nothing could be placed: leave the plates and the instances exactly as they were.
// Rebuilding the plates would only re-home the instances by their bounds, which moves
// a too large item to whichever plate it happens to reach over.
const bool nothing_arranged = !m_selected.empty() &&
std::all_of(m_selected.begin(), m_selected.end(), [](const ArrangePolygon &ap) { return !ap.is_arranged(); });
if (nothing_arranged) {
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
m_plater->m_arrange_running.store(false);
return;
}
// Unprintable items go to the last virtual bed
int beds = 0;
@@ -870,12 +881,13 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
}
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
//BBS: reload all objects due to arrange
//BBS: reload all objects due to arrange. Plates are never recycled here: arranging
// must not remove a plate the user created, even when the arrange left it empty.
if (only_on_partplate) {
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true, current_plate_index);
plate_list.rebuild_plates_after_arrangement(false, true, current_plate_index);
}
else {
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true);
plate_list.rebuild_plates_after_arrangement(false, true);
}
// unlock the plates we just locked
+2 -1
View File
@@ -248,7 +248,6 @@ void FillBedJob::process(Ctl &ctl)
if (m_object_idx == -1 || m_selected.empty()) return;
update_arrange_params(params, m_plater->config(), m_selected);
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
auto &partplate_list = m_plater->get_partplate_list();
auto &print = wxGetApp().plater()->get_partplate_list().get_current_fff_print();
@@ -260,6 +259,8 @@ void FillBedJob::process(Ctl &ctl)
update_selected_items_inflation(m_selected, m_plater->config(), params);
update_unselected_items_inflation(m_unselected, m_plater->config(), params);
// After the inflation, which may give back part of the bed margin.
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
bool do_stop = false;
params.stopcondition = [&ctl, &do_stop]() {
+6 -11
View File
@@ -7048,19 +7048,14 @@ void PartPlateList::postprocess_arrange_polygon(arrangement::ArrangePolygon& arr
{
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": bed_idx %1%, selected %2%, translation(x) %3%, (y) %4%") % arrange_polygon.bed_idx % selected % unscale<double>(arrange_polygon.translation(X)) % unscale<double>(arrange_polygon.translation(Y));
// UNARRANGED means the item could not be placed, so it has no bed to map onto and
// must be left where the user had it. Moving it to a virtual bed here would shift
// it off the plate, because no such plate is created for it afterwards.
if (arrange_polygon.bed_idx == arrangement::UNARRANGED)
return;
if ((selected) || (arrange_polygon.bed_idx != PartPlateList::MAX_PLATES_COUNT))
{
if (arrange_polygon.bed_idx == -1)
{
// outarea for large object
arrange_polygon.bed_idx = m_plate_list.size();
BoundingBox apbox = get_extents(arrange_polygon.transformed_poly()); // the item may have been rotated
auto apbox_size = apbox.size();
arrange_polygon.translation(X) = 0.5 * apbox_size[0];
arrange_polygon.translation(Y) = scaled<double>(static_cast<double>(m_plate_depth)) - 0.5 * apbox_size[1];
}
arrange_polygon.row = arrange_polygon.bed_idx / m_plate_cols;
arrange_polygon.col = arrange_polygon.bed_idx % m_plate_cols;
arrange_polygon.translation(X) += scaled<double>(plate_stride_x() * arrange_polygon.col);
+11 -1
View File
@@ -11034,7 +11034,17 @@ void Plater::priv::split_volume()
void Plater::priv::scale_selection_to_fit_print_volume()
{
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(this->bed.build_volume());
const BuildVolume volume = this->bed.build_volume();
// Every plate shares the bed size, but each sits at its own origin: fit onto the
// plate the selection is on instead of always onto the first one.
PartPlate* plate = this->partplate_list.get_curr_plate();
const Vec3d bed_center = plate != nullptr ? plate->get_center_origin() :
Vec3d(volume.bed_center().x(), volume.bed_center().y(), 0.);
// On a multi-nozzle printer each nozzle reaches only part of the bed, so a model fitted
// to the bed can still exceed a nozzle's printable range. Fit into the volume every
// nozzle shares, which is also the area Arrange places items in.
const BoundingBoxf3 print_volume = plate != nullptr ? plate->get_build_volume(true) : volume.bounding_volume();
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(volume, Vec2d(bed_center.x(), bed_center.y()), print_volume);
#else
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(*config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+10 -10
View File
@@ -17,6 +17,7 @@
#include "libslic3r/Color.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include <string>
#include <vector>
@@ -1472,11 +1473,9 @@ void Selection::flattening_rotate(const Vec3d& normal)
for (unsigned int i : m_list) {
GLVolume& v = *(*m_volumes)[i];
// Normal transformed from the object coordinate space to the world coordinate space.
// Additional rotation to align the face normal with the down vector in the world coordinate space.
const Geometry::Transformation& old_inst_trafo = v.get_instance_transformation();
const Vec3d tnormal = old_inst_trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
// Additional rotation to align tnormal with the down vector in the world coordinate space.
const Transform3d rotation_matrix = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
const Transform3d rotation_matrix = lay_on_face_rotation(normal, old_inst_trafo.get_matrix_no_offset());
v.set_instance_transformation(old_inst_trafo.get_offset_matrix() * rotation_matrix * old_inst_trafo.get_matrix_no_offset());
}
@@ -1496,7 +1495,7 @@ void Selection::scale(const Vec3d& scale, TransformationType transformation_type
}
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume)
{
auto fit = [this](double s, Vec3d offset) {
if (s <= 0.0 || s == 1.0)
@@ -1526,8 +1525,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
//wxGetApp().obj_manipul()->set_dirty();
};
auto fit_rectangle = [this, fit](const BuildVolume& volume) {
const BoundingBoxf3 print_volume = volume.bounding_volume();
auto fit_rectangle = [this, fit, &print_volume]() {
const Vec3d print_volume_size = print_volume.size();
// adds 1/100th of a mm on all sides to avoid false out of print volume detections due to floating-point roundings
@@ -1541,7 +1539,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
fit(std::min(sx, std::min(sy, sz)), print_volume.center() - get_bounding_box().center());
};
auto fit_circle = [this, fit](const BuildVolume& volume) {
auto fit_circle = [this, fit, &bed_center](const BuildVolume& volume) {
const Geometry::Circled& print_circle = volume.circle();
double print_circle_radius = unscale<double>(print_circle.radius);
@@ -1572,7 +1570,9 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
const double s = std::min(print_circle_radius / circle_radius, volume.printable_height() / max_z);
const Vec3d sel_center = get_bounding_box().center();
const Vec3d offset = s * (Vec3d(unscale<double>(circle.center.x()), unscale<double>(circle.center.y()), 0.5 * max_z) - sel_center);
const Vec3d print_center = { unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height() };
// The active plate's bed is the same circle, moved by its centre.
const Vec3d plate_shift = Vec3d(bed_center.x() - volume.bed_center().x(), bed_center.y() - volume.bed_center().y(), 0.);
const Vec3d print_center = Vec3d(unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height()) + plate_shift;
fit(s, print_center - (sel_center + offset));
};
@@ -1581,7 +1581,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
switch (volume.type())
{
case BuildVolume_Type::Rectangle: { fit_rectangle(volume); break; }
case BuildVolume_Type::Rectangle: { fit_rectangle(); break; }
case BuildVolume_Type::Circle: { fit_circle(volume); break; }
default: { break; }
}
+4 -1
View File
@@ -354,7 +354,10 @@ public:
void flattening_rotate(const Vec3d& normal);
void scale(const Vec3d& scale, TransformationType transformation_type);
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
void scale_to_fit_print_volume(const BuildVolume& volume);
// bed_center is the centre of the plate the selection is on: every plate shares the
// bed size, but each sits at its own origin. print_volume is the box the selection must
// fit in, which is the area every nozzle reaches rather than the whole bed.
void scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume);
#else
void scale_to_fit_print_volume(const DynamicPrintConfig& config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+86 -123
View File
@@ -528,6 +528,81 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
return check_path_access(boost::filesystem::path(path_str), is_write);
}
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths)
{
if (plugin_key.empty() || paths.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> missing;
for (const std::string& path : paths) {
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
missing.push_back(path);
}
if (missing.empty())
return true;
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
auto show_dialog = [&descriptor, &missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
};
bool granted = false;
if (wxIsMainThread()) {
granted = show_dialog();
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation)
{
m_last_violation = violation;
@@ -763,7 +838,7 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
// Thread creation exposes no user-supplied target. Use a fixed sentinel so the grant
// persists per plugin: the permission list matches targets by exact string, and the
// started function's repr embeds an address that changes every run.
targets.emplace_back(kThreadPermissionTarget);
targets.emplace_back("thread");
return targets;
default:
break;
@@ -819,20 +894,6 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
return std::find(granted.begin(), granted.end(), target) != granted.end();
}
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata the
// writer needs so clicking Yes still creates the JSON file.
void seed_install_metadata(const PluginDescriptor& descriptor, PluginInstallState& state)
{
if (!state.plugin_name.empty())
return;
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
bool persist_permission(const std::string& plugin_key,
PluginInstallState& state,
std::vector<std::string>& permission_list,
@@ -842,7 +903,15 @@ bool persist_permission(const std::string& plugin_key,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
seed_install_metadata(descriptor, state);
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
// needed by the writer so clicking Yes still creates the JSON file.
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
if (!has_permission(permission_list, target))
permission_list.push_back(target);
@@ -928,13 +997,6 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
return dialog.ShowModal() == wxID_YES;
}
// Shows a prompt whose body may cover several permission buckets. Must run on the GUI thread.
bool prompt_for_permission_message(const wxString& message)
{
wxMessageDialog dialog(nullptr, message, _L("Plugin permissions"), wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
@@ -961,7 +1023,7 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
// request_permissions runs on -- would therefore deadlock the application (the
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
@@ -1085,105 +1147,6 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
} // namespace PluginAuditDetail
bool PluginAuditManager::request_permissions(const std::string& plugin_key, const PluginPermissions& requested)
{
if (plugin_key.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
// Per bucket, keep only the targets that no earlier grant already covers. Dropping these
// before prompting keeps a repeated declaration from asking again for what is on disk.
struct MissingBucket {
AuditEventCategory category;
std::vector<std::string> targets;
};
std::vector<MissingBucket> missing_buckets;
const auto collect_missing = [&missing_buckets](AuditEventCategory category,
const std::vector<std::string>& requested_targets,
const std::vector<std::string>& granted_targets) {
MissingBucket bucket{category, {}};
for (const std::string& target : requested_targets) {
if (target.empty())
continue;
if (std::find(granted_targets.begin(), granted_targets.end(), target) == granted_targets.end() &&
std::find(bucket.targets.begin(), bucket.targets.end(), target) == bucket.targets.end())
bucket.targets.push_back(target);
}
if (!bucket.targets.empty())
missing_buckets.push_back(std::move(bucket));
};
collect_missing(AuditEventCategory::FsRead, requested.fs_read, state.permissions.fs_read);
collect_missing(AuditEventCategory::FsReadWrite, requested.fs_readwrite, state.permissions.fs_readwrite);
collect_missing(AuditEventCategory::Http, requested.network_http, state.permissions.network_http);
collect_missing(AuditEventCategory::Socket, requested.network_socket, state.permissions.network_socket);
collect_missing(AuditEventCategory::ProcessCreate, requested.process, state.permissions.process);
collect_missing(AuditEventCategory::Threading, requested.threading, state.permissions.threading);
if (missing_buckets.empty())
return true;
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
const auto build_message = [](const std::string& plugin_name, const std::vector<MissingBucket>& buckets) {
wxString message;
for (const MissingBucket& bucket : buckets) {
wxString targets;
for (const std::string& target : bucket.targets)
targets += wxString::FromUTF8(target.c_str()) + "\n";
if (!message.IsEmpty())
message += "\n\n";
message += PluginAuditDetail::audit_message(bucket.category, wxString::FromUTF8(plugin_name.c_str()),
wxEmptyString, targets, bucket.targets.size());
}
return message;
};
bool granted = false;
if (wxIsMainThread()) {
granted = PluginAuditDetail::prompt_for_permission_message(build_message(descriptor.name, missing_buckets));
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, plugin_name = descriptor.name, missing_buckets, build_message]() {
result->set_value(PluginAuditDetail::prompt_for_permission_message(build_message(plugin_name, missing_buckets)));
});
granted = future.get();
}
if (!granted)
return false;
// The dialog can stay open while another grant is persisted; re-read before saving so this
// request adds to the current state instead of replacing it.
PluginInstallState latest_state;
if (read_install_state(boost::filesystem::path(descriptor.plugin_root), latest_state))
state = std::move(latest_state);
PluginAuditDetail::seed_install_metadata(descriptor, state);
for (const MissingBucket& bucket : missing_buckets) {
std::vector<std::string>* permission_list =
PluginAuditDetail::permission_list_for(bucket.category, /*by_event=*/false, state.permissions);
if (!permission_list)
continue;
for (const std::string& target : bucket.targets)
if (std::find(permission_list->begin(), permission_list->end(), target) == permission_list->end())
permission_list->push_back(target);
}
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::record_root_frame()
{
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
+6 -12
View File
@@ -13,12 +13,6 @@
namespace Slic3r {
struct PluginPermissions;
// Thread creation exposes no user-supplied target, so both the runtime audit event and a
// request_permissions(threading=True) declaration use this fixed target.
inline constexpr const char* kThreadPermissionTarget = "thread";
struct AuditDecision {
bool allowed = true;
std::string reason;
@@ -136,12 +130,12 @@ public:
AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write);
AuditDecision check_open(const std::string& path, const std::string& mode);
// Ask the user to grant the requested permissions. The request may originate on a plugin
// load worker, so the implementation marshals the modal dialog to the wx main thread.
// Targets already persisted for the plugin are not shown or asked for again. Returns true
// only when every missing target was granted and persisted; denial aborts the plugin load
// without adding a permission.
bool request_permissions(const std::string& plugin_key, const PluginPermissions& requested);
// Ask the user to grant the requested filesystem-read paths. The request may originate on a
// plugin load worker, so the implementation marshals the modal dialog to the wx main thread.
// Returns true only when every missing path was granted and persisted; denial aborts the plugin
// load without adding a permission.
bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths);
void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const;
+26 -60
View File
@@ -49,7 +49,7 @@ thread_local std::string g_active_plugin_key;
std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, PluginPermissions> g_pending_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
struct PluginInstanceHandle
{
// The C++ plugin interface points into a Python object. Keep both alive through one
@@ -88,7 +88,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
g_pending_package.erase(package);
}
g_pending_permissions.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key);
}
} // namespace
@@ -113,7 +113,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
// for this same entry path.
g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key);
g_pending_permissions.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key);
}
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin.
@@ -144,7 +144,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
std::lock_guard<std::mutex> lock(g_registry_mutex);
g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key);
g_pending_permissions.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key);
};
try {
@@ -177,27 +177,23 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
// Permission declarations are collected while register_capabilities() runs so the
// plugin can describe its needs without touching wx from the Python load worker. Ask
// only after registration returns, before capability instances are materialized.
PluginPermissions requested_permissions;
std::vector<std::string> fs_read_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_permissions.find(capture_key);
if (it != g_pending_permissions.end()) {
requested_permissions = std::move(it->second);
g_pending_permissions.erase(it);
auto it = g_pending_fs_read_permissions.find(capture_key);
if (it != g_pending_fs_read_permissions.end()) {
fs_read_permissions = std::move(it->second);
g_pending_fs_read_permissions.erase(it);
}
}
const bool has_permission_request =
!requested_permissions.fs_read.empty() || !requested_permissions.fs_readwrite.empty() ||
!requested_permissions.network_http.empty() || !requested_permissions.network_socket.empty() ||
!requested_permissions.process.empty() || !requested_permissions.threading.empty();
if (has_permission_request) {
if (!fs_read_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_permissions(plugin_key, requested_permissions);
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
}
if (!granted) {
error = _u8L("Plugin permission request denied");
error = _u8L("Plugin filesystem read permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
@@ -326,7 +322,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
// may already have registered under this key.
g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key);
g_pending_permissions.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key);
}
if (g_active_plugin_key == plugin_key)
@@ -353,7 +349,7 @@ void PythonPluginBridge::clear_pending_captures()
(void) pkg.release();
}
g_pending_package.clear();
g_pending_permissions.clear();
g_pending_fs_read_permissions.clear();
g_active_plugin_key.clear();
return;
}
@@ -362,7 +358,7 @@ void PythonPluginBridge::clear_pending_captures()
BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)";
g_pending_capabilities.clear();
g_pending_package.clear();
g_pending_permissions.clear();
g_pending_fs_read_permissions.clear();
g_active_plugin_key.clear();
}
@@ -562,54 +558,24 @@ void bind_python_api(pybind11::module_& m)
m.def(
"request_permissions",
[](const std::vector<std::string>& fs_read,
const std::vector<std::string>& fs_readwrite,
const std::vector<std::string>& network_http,
const std::vector<std::string>& network_socket,
const std::vector<std::string>& process,
bool threading) {
[](const std::vector<std::string>& fs_read) {
if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context");
std::lock_guard<std::mutex> lock(g_registry_mutex);
PluginPermissions& requested = g_pending_permissions[g_active_plugin_key];
const auto add_targets = [](std::vector<std::string>& bucket, const char* name,
const std::vector<std::string>& targets) {
for (const std::string& target : targets) {
if (target.empty())
throw py::value_error(std::string("request_permissions(") + name +
"=...) does not accept empty values");
if (std::find(bucket.begin(), bucket.end(), target) == bucket.end())
bucket.push_back(target);
}
};
add_targets(requested.fs_read, "fs_read", fs_read);
add_targets(requested.fs_readwrite, "fs_readwrite", fs_readwrite);
add_targets(requested.network_http, "network_http", network_http);
add_targets(requested.network_socket, "network_socket", network_socket);
add_targets(requested.process, "process", process);
if (threading && std::find(requested.threading.begin(), requested.threading.end(),
kThreadPermissionTarget) == requested.threading.end())
requested.threading.emplace_back(kThreadPermissionTarget);
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
if (std::find(requested.begin(), requested.end(), path) == requested.end())
requested.push_back(path);
}
},
py::arg("fs_read") = std::vector<std::string>{},
py::arg("fs_readwrite") = std::vector<std::string>{},
py::arg("network_http") = std::vector<std::string>{},
py::arg("network_socket") = std::vector<std::string>{},
py::arg("process") = std::vector<std::string>{},
py::arg("threading") = false,
R"pbdoc(Request permissions while OrcaSlicer loads your module.
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
Each bucket takes the target strings the plugin expects the corresponding audit event to
report: filesystem paths for fs_read and fs_readwrite, URLs for network_http, socket addresses
for network_socket, and commands for process. threading=True requests permission to start
threads. Targets already granted to the plugin are not asked for again.
The host presents one request to the user after register_capabilities() returns and before any
capability instance is created. Denying the request aborts the plugin load.)pbdoc");
The host presents the request to the user after register_capabilities() returns. Denying the
request aborts the plugin load.)pbdoc");
m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty())
+28
View File
@@ -220,11 +220,39 @@ TEST_CASE("An item larger than the bed cannot be placed", "[Arrange]")
ArrangePolygons items;
items.emplace_back(make_square(scaled(20.)));
items.emplace_back(make_square(scaled(400.))); // far bigger than the bed
// An item that cannot be placed keeps the position it came in with, so the
// caller can leave it where the user had it.
items[1].translation = {scaled(10.), scaled(20.)};
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
REQUIRE(items[0].bed_idx == 0);
REQUIRE(items[1].bed_idx == UNARRANGED);
REQUIRE(items[1].translation.x() == scaled(10.));
REQUIRE(items[1].translation.y() == scaled(20.));
}
TEST_CASE("An item filling the printable area is arranged", "[Arrange]")
{
// The arrange bed keeps a margin from the printable area and the item is widened by
// its brim. Neither may keep an item that fits the printable area from being placed.
DynamicPrintConfig cfg = bed_config(); // 200 x 200
ArrangeParams p = quiet_params();
ArrangePolygons items = squares(1, 197.);
items.front().brim_width = 1.;
update_arrange_params(p, &cfg, items);
update_selected_items_inflation(items, &cfg, p);
arrange(items, get_shrink_bedpts(&cfg, p), p);
REQUIRE(items.front().bed_idx == 0);
// Larger than the printable area is still refused.
ArrangeParams p_large = quiet_params();
ArrangePolygons large = squares(1, 201.);
update_arrange_params(p_large, &cfg, large);
update_selected_items_inflation(large, &cfg, p_large);
arrange(large, get_shrink_bedpts(&cfg, p_large), p_large);
REQUIRE(large.front().bed_idx == UNARRANGED);
}
TEST_CASE("Items overflowing one bed spill onto virtual beds", "[Arrange]")
+17
View File
@@ -187,6 +187,23 @@ TEST_CASE("A part rests on its largest hull face even when parallel inner faces
check_on_bed(plate);
}
TEST_CASE("A box laid on its top face is turned over about X", "[LayOnFace]")
{
// A face pointing straight up leaves the half turn's axis open to every horizontal
// direction. It must be the fixed X flip both times, keep the footprint and not follow
// rounding noise to an arbitrary angle.
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 });
lay_on_face(box, 0, { 0, 0, 1 });
check_size(box, { 40, 20, 10 });
check_on_bed(box);
const Matrix3d rotation = box.instances.front()->get_matrix_no_offset().linear();
CHECK_THAT(rotation(0, 0), WithinAbs(1., 1e-9));
CHECK_THAT(rotation(1, 1), WithinAbs(-1., 1e-9));
CHECK_THAT(rotation(2, 2), WithinAbs(-1., 1e-9));
}
TEST_CASE("Faces are selected in object coordinates whatever the instance rotation", "[LayOnFace]")
{
Model model;