mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e1e885d5 | ||
|
|
777f14fad4 | ||
|
|
42bd103958 | ||
|
|
6626377abc | ||
|
|
74d4392dae | ||
|
|
0d5b3a79d7 |
No files matched your search
@@ -466,13 +466,16 @@ jobs:
|
|||||||
# the bounds checks are compiled in, so a stripped exe still catches them.
|
# the bounds checks are compiled in, so a stripped exe still catches them.
|
||||||
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
|
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
|
||||||
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
|
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
|
||||||
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, and
|
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, the
|
||||||
# the icon SVGs (NativeCommands icon names are checked against them).
|
# icon SVGs (NativeCommands icon names are checked against them), and the
|
||||||
|
# one font the text-import tests vectorize.
|
||||||
find "$d" -mindepth 1 -maxdepth 1 -type d \
|
find "$d" -mindepth 1 -maxdepth 1 -type d \
|
||||||
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
|
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
|
||||||
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers ! -name images -exec rm -rf {} +
|
find "$d/resources" -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name profiles ! -name printers ! -name images ! -name fonts -exec rm -rf {} +
|
||||||
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
|
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
|
||||||
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
|
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find "$d/resources/fonts" -mindepth 1 -maxdepth 1 ! -name HarmonyOS_Sans_SC_Regular.ttf -exec rm -rf {} +
|
||||||
tar -cf flatpak-test-asset.tar flatpak_app "$d"
|
tar -cf flatpak-test-asset.tar flatpak_app "$d"
|
||||||
- name: Upload flatpak test asset
|
- name: Upload flatpak test asset
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|||||||
+41
-16
@@ -377,7 +377,8 @@ public:
|
|||||||
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
|
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
|
||||||
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
|
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
|
||||||
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
|
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
|
||||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0;
|
// keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
|
||||||
|
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
|
||||||
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
|
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
|
||||||
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
|
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
|
||||||
// Resize the vector of values, copy the newly added values from opt_default if provided.
|
// Resize the vector of values, copy the newly added values from opt_default if provided.
|
||||||
@@ -580,7 +581,7 @@ public:
|
|||||||
//set a item related with extruder variants when loading user config, only set the different value of some extruder
|
//set a item related with extruder variants when loading user config, only set the different value of some extruder
|
||||||
//rhs: item from user config
|
//rhs: item from user config
|
||||||
//diff_index: which index in this vector need to be set
|
//diff_index: which index in this vector need to be set
|
||||||
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override
|
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
|
||||||
{
|
{
|
||||||
if (rhs->type() == this->type()) {
|
if (rhs->type() == this->type()) {
|
||||||
// Assign the first value of the rhs vector.
|
// Assign the first value of the rhs vector.
|
||||||
@@ -592,7 +593,7 @@ public:
|
|||||||
for (size_t i = 0; i < diff_index.size(); i++) {
|
for (size_t i = 0; i < diff_index.size(); i++) {
|
||||||
if (diff_index[i] != -1) {
|
if (diff_index[i] != -1) {
|
||||||
for (size_t j = 0; j < stride; j++) {
|
for (size_t j = 0; j < stride; j++) {
|
||||||
if (!other->is_nil(diff_index[i] * stride))
|
if (keep_nil || !other->is_nil(diff_index[i] * stride))
|
||||||
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
|
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,8 +842,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator==(const ConfigOption &rhs) const override
|
bool operator==(const ConfigOption &rhs) const override
|
||||||
@@ -1033,8 +1038,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
int value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigOptionInt& operator=(const ConfigOption *opt)
|
ConfigOptionInt& operator=(const ConfigOption *opt)
|
||||||
@@ -1252,8 +1261,12 @@ public:
|
|||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
// don't try to parse the trailing % since it's optional
|
// don't try to parse the trailing % since it's optional
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1365,10 +1378,14 @@ public:
|
|||||||
bool deserialize(const std::string &str, bool append = false) override
|
bool deserialize(const std::string &str, bool append = false) override
|
||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
this->percent = str.find_first_of("%") != std::string::npos;
|
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
iss >> this->value;
|
double value;
|
||||||
return !iss.fail();
|
iss >> value;
|
||||||
|
if (iss.fail())
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
this->percent = str.find_first_of("%") != std::string::npos;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1538,8 +1555,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
|
Vec2d value;
|
||||||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
|
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
|
||||||
|
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1658,8 +1679,12 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
|
Vec3d value;
|
||||||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
|
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
|
||||||
|
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
|
||||||
|
return false;
|
||||||
|
this->value = value;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
|
|||||||
{
|
{
|
||||||
ConfigOption *opt_src = config.option(option);
|
ConfigOption *opt_src = config.option(option);
|
||||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||||
opt_dst->set(opt_src);
|
opt_dst->set(opt_src);
|
||||||
else {
|
else {
|
||||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||||
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
|
|||||||
{
|
{
|
||||||
ConfigOption *opt_src = preset.config.option(option);
|
ConfigOption *opt_src = preset.config.option(option);
|
||||||
ConfigOption *opt_dst = temp_config.option(option, true);
|
ConfigOption *opt_dst = temp_config.option(option, true);
|
||||||
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
|
||||||
opt_dst->set(opt_src);
|
opt_dst->set(opt_src);
|
||||||
else {
|
else {
|
||||||
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
|
||||||
@@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
|
|||||||
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
|
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
|
||||||
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
|
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
|
||||||
if (m_type == Preset::TYPE_FILAMENT) {
|
if (m_type == Preset::TYPE_FILAMENT) {
|
||||||
for (const std::string& opt_key : filament_extruder_override_keys) {
|
for (const std::string& opt_key : preset.config.keys()) {
|
||||||
if (key_values.count(opt_key))
|
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
|
||||||
continue; // already carried by the diff
|
continue; // not an override, or already carried by the diff
|
||||||
|
const ConfigOptionDef *def = print_config_def.get(opt_key);
|
||||||
|
if (def == nullptr || !def->default_value || def->default_value->is_nil())
|
||||||
|
continue; // the option's default is nil too, so an absent key cannot come back as a value
|
||||||
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
|
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
|
||||||
if (opt_vec == nullptr)
|
if (opt_vec == nullptr)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Some filament override parameters are generated from filament_extruder_override_keys,
|
// Some filament override parameters are generated from filament_extruder_override_keys,
|
||||||
// while filament_retract_length_nc is defined separately. Keep the generator list
|
// while filament_retract_length_nc and the ironing overrides are defined separately.
|
||||||
// unchanged and use this helper for behavior checks that need the full override set.
|
// Keep the generator list unchanged and use this helper for behavior checks that need
|
||||||
|
// the full override set. A nil in one of these keys means the override is disabled, so
|
||||||
|
// child presets store and load it verbatim instead of treating nil as "inherit".
|
||||||
bool is_filament_extruder_override_key(const std::string &opt_key)
|
bool is_filament_extruder_override_key(const std::string &opt_key)
|
||||||
{
|
{
|
||||||
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
|
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
|
||||||
opt_key == "filament_retract_length_nc";
|
opt_key == "filament_retract_length_nc" ||
|
||||||
|
opt_key == "filament_ironing_flow" ||
|
||||||
|
opt_key == "filament_ironing_spacing" ||
|
||||||
|
opt_key == "filament_ironing_inset" ||
|
||||||
|
opt_key == "filament_ironing_speed";
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
|
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
|
||||||
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
|
|||||||
opt_src->set(opt_target);
|
opt_src->set(opt_target);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride);
|
// An override's nil means "disabled" and is the child's value; every other nil column inherits.
|
||||||
|
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name)
|
|||||||
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
|
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
|
||||||
|
// operation, so the raising frame bounds everything the operation does. Every other mapped event
|
||||||
|
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
|
||||||
|
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
|
||||||
|
// are raised in iglob(), which returns its lazy generator before any directory is listed.
|
||||||
|
static const std::unordered_set<std::string> operation_root_events{
|
||||||
|
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
|
||||||
|
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
|
||||||
|
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
|
||||||
|
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
|
||||||
|
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
|
||||||
|
"shutil.unpack_archive",
|
||||||
|
};
|
||||||
|
|
||||||
// True for the categories whose targets are filesystem paths, as opposed to a network
|
// True for the categories whose targets are filesystem paths, as opposed to a network
|
||||||
// address or a process command line -- the deny-path and allowed-root checks only make sense
|
// address or a process command line -- the deny-path and allowed-root checks only make sense
|
||||||
// against a path.
|
// against a path.
|
||||||
@@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
|
||||||
|
static const char* category_name(AuditEventCategory category)
|
||||||
|
{
|
||||||
|
switch (category) {
|
||||||
|
case AuditEventCategory::FsRead: return "FsRead";
|
||||||
|
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
|
||||||
|
case AuditEventCategory::FsCreate: return "FsCreate";
|
||||||
|
case AuditEventCategory::FsDelete: return "FsDelete";
|
||||||
|
case AuditEventCategory::Http: return "Http";
|
||||||
|
case AuditEventCategory::Socket: return "Socket";
|
||||||
|
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
|
||||||
|
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
|
||||||
|
case AuditEventCategory::Threading: return "Threading";
|
||||||
|
case AuditEventCategory::None: break;
|
||||||
|
}
|
||||||
|
return "None";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Path safety
|
// Path safety
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
|
|||||||
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
||||||
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
||||||
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
||||||
|
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
|
||||||
|
thread_local int PluginAuditManager::m_releasing_frames = 0;
|
||||||
thread_local bool PluginAuditManager::m_audit_denial_pending = false;
|
thread_local bool PluginAuditManager::m_audit_denial_pending = false;
|
||||||
thread_local bool PluginAuditManager::m_has_last_violation = false;
|
thread_local bool PluginAuditManager::m_has_last_violation = false;
|
||||||
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
||||||
|
|
||||||
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
|
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
|
||||||
const std::string& capability_name)
|
const std::string& capability_name)
|
||||||
: m_previous_id(PluginAuditManager::instance().current_plugin())
|
: m_plugin_key(plugin_key)
|
||||||
|
, m_capability(capability_name)
|
||||||
|
, m_previous_id(PluginAuditManager::instance().current_plugin())
|
||||||
, m_previous_capability(PluginAuditManager::instance().current_capability())
|
, m_previous_capability(PluginAuditManager::instance().current_capability())
|
||||||
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
|
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
|
||||||
|
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
|
||||||
{
|
{
|
||||||
PluginAuditManager::instance().set_current_plugin(plugin_key);
|
PluginAuditManager::instance().set_current_plugin(plugin_key);
|
||||||
PluginAuditManager::instance().set_current_capability(capability_name);
|
PluginAuditManager::instance().set_current_capability(capability_name);
|
||||||
PluginAuditManager::m_scoped_allowed_roots.clear();
|
PluginAuditManager::m_scoped_allowed_roots.clear();
|
||||||
|
PluginAuditManager::m_root_frames.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
ScopedPluginAuditContext::~ScopedPluginAuditContext()
|
ScopedPluginAuditContext::~ScopedPluginAuditContext()
|
||||||
{
|
{
|
||||||
PluginAuditManager::instance().set_current_plugin(m_previous_id);
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||||
PluginAuditManager::instance().set_current_capability(m_previous_capability);
|
|
||||||
|
// Let this scope's root frames go while it is still the current context, and as its own
|
||||||
|
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
|
||||||
|
// must never be judged as the outer plugin, or bypass the audit when there is none. A
|
||||||
|
// finalizer may record a new root frame, so repeat until none is held.
|
||||||
|
if (!PluginAuditManager::m_root_frames.empty()) {
|
||||||
|
assert(PyGILState_Check());
|
||||||
|
mgr.set_current_plugin(m_plugin_key);
|
||||||
|
mgr.set_current_capability(m_capability);
|
||||||
|
while (!PluginAuditManager::m_root_frames.empty()) {
|
||||||
|
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
|
||||||
|
PluginAuditManager::m_root_frames.clear();
|
||||||
|
mgr.release_frames(frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr.set_current_plugin(m_previous_id);
|
||||||
|
mgr.set_current_capability(m_previous_capability);
|
||||||
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
|
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
|
||||||
|
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
|
|
||||||
const std::vector<std::string>& call_site_ids) const
|
|
||||||
{
|
|
||||||
if (plugin_key.empty() || call_site_ids.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
auto it = m_approved_call_sites.find(plugin_key);
|
|
||||||
if (it == m_approved_call_sites.end())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (const auto& id : call_site_ids)
|
|
||||||
if (it->second.count(id))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
|
||||||
const std::vector<std::string>& call_site_ids)
|
|
||||||
{
|
|
||||||
if (plugin_key.empty() || call_site_ids.empty())
|
|
||||||
return;
|
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
auto& approved = m_approved_call_sites[plugin_key];
|
|
||||||
approved.insert(call_site_ids.begin(), call_site_ids.end());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The C-level audit hook
|
// The C-level audit hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -663,40 +692,88 @@ std::string python_str(PyObject* object)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
// The interpreter's stdlib folders, and the site-packages folders inside them that hold
|
||||||
{
|
// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
|
||||||
std::vector<std::string> ids;
|
// touched while the GIL is held.
|
||||||
if (plugin_root.empty())
|
static std::vector<boost::filesystem::path> s_stdlib_dirs;
|
||||||
return ids;
|
static std::vector<boost::filesystem::path> s_site_package_dirs;
|
||||||
|
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
|
||||||
|
|
||||||
|
void collect_stdlib_dirs()
|
||||||
|
{
|
||||||
|
s_stdlib_dirs.clear();
|
||||||
|
s_site_package_dirs.clear();
|
||||||
|
s_stdlib_file_verdicts.clear();
|
||||||
|
|
||||||
|
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
|
||||||
|
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
|
||||||
|
if (paths && PyDict_Check(paths)) {
|
||||||
|
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
|
||||||
|
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
|
||||||
|
if (dir.empty())
|
||||||
|
return;
|
||||||
|
boost::system::error_code ec;
|
||||||
|
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
|
||||||
|
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
|
||||||
|
};
|
||||||
|
collect("stdlib", s_stdlib_dirs);
|
||||||
|
collect("platstdlib", s_stdlib_dirs);
|
||||||
|
collect("purelib", s_site_package_dirs);
|
||||||
|
collect("platlib", s_site_package_dirs);
|
||||||
|
}
|
||||||
|
Py_XDECREF(paths);
|
||||||
|
Py_XDECREF(sysconfig);
|
||||||
|
if (PyErr_Occurred()) {
|
||||||
|
PyErr_Clear();
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_stdlib_frame(PyObject* frame)
|
||||||
|
{
|
||||||
|
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
|
||||||
|
const std::string filename = python_unicode(code->co_filename);
|
||||||
|
Py_DECREF(code);
|
||||||
|
return PluginAuditManager::is_stdlib_file(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
|
||||||
|
// caller runs, because a running activation keeps its frame object alive.
|
||||||
|
std::vector<PyObject*> stack_frames()
|
||||||
|
{
|
||||||
|
std::vector<PyObject*> frames;
|
||||||
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
|
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
|
||||||
Py_XINCREF(frame); // normalize to an owned reference for the loop below
|
Py_XINCREF(frame); // normalize to an owned reference for the loop below
|
||||||
|
|
||||||
while (frame) {
|
while (frame) {
|
||||||
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
|
frames.push_back(reinterpret_cast<PyObject*>(frame));
|
||||||
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
|
|
||||||
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
|
|
||||||
|
|
||||||
std::string id;
|
|
||||||
if (!is_plugin_frame && !filename.empty()) {
|
|
||||||
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
|
|
||||||
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
|
|
||||||
}
|
|
||||||
Py_DECREF(code);
|
|
||||||
|
|
||||||
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
|
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
|
||||||
Py_DECREF(frame);
|
Py_DECREF(frame);
|
||||||
frame = back;
|
frame = back;
|
||||||
|
|
||||||
if (is_plugin_frame)
|
|
||||||
break;
|
|
||||||
if (!id.empty())
|
|
||||||
ids.push_back(std::move(id));
|
|
||||||
}
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
|
// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
|
||||||
|
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
|
||||||
|
// it stays held until the scope ends.
|
||||||
|
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
|
||||||
|
{
|
||||||
|
if (held.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
return ids;
|
const std::vector<PyObject*> stack = stack_frames();
|
||||||
|
const auto running = [&stack](PyObject* frame) {
|
||||||
|
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
|
||||||
|
return true;
|
||||||
|
// NULL once the generator has finished or been deleted: its frame then owns its own data.
|
||||||
|
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
|
||||||
|
const bool suspended = generator != nullptr;
|
||||||
|
Py_XDECREF(generator);
|
||||||
|
return suspended;
|
||||||
|
};
|
||||||
|
const auto returned = std::stable_partition(held.begin(), held.end(), running);
|
||||||
|
dropped.insert(dropped.end(), returned, held.end());
|
||||||
|
held.erase(returned, held.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
static const std::unordered_set<std::string> two_path_fs_events{
|
static const std::unordered_set<std::string> two_path_fs_events{
|
||||||
@@ -717,12 +794,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
|
|||||||
{"socket.getnameinfo", {0}},
|
{"socket.getnameinfo", {0}},
|
||||||
{"socket.getservbyname", {0}},
|
{"socket.getservbyname", {0}},
|
||||||
{"socket.getservbyport", {0}},
|
{"socket.getservbyport", {0}},
|
||||||
|
{"socket.sendto", {1}},
|
||||||
|
{"socket.sendmsg", {1}},
|
||||||
|
|
||||||
{"os.system", {0}},
|
{"os.system", {0}},
|
||||||
{"subprocess.Popen", {1, 0}},
|
{"subprocess.Popen", {1, 0}},
|
||||||
{"os.posix_spawn", {1, 0}},
|
{"os.posix_spawn", {1, 0}},
|
||||||
{"os.spawn", {2, 1}},
|
{"os.spawn", {2, 1}},
|
||||||
{"os.startfile", {0}},
|
{"os.startfile", {0}},
|
||||||
|
{"os.startfile/2", {0}},
|
||||||
{"pty.spawn", {0}},
|
{"pty.spawn", {0}},
|
||||||
{"_winapi.CreateProcess", {1, 0}},
|
{"_winapi.CreateProcess", {1, 0}},
|
||||||
{"_posixsubprocess.fork_exec", {0}},
|
{"_posixsubprocess.fork_exec", {0}},
|
||||||
@@ -767,7 +847,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
const auto it = audit_target_arg_indices.find(event_name);
|
const auto it = audit_target_arg_indices.find(event_name);
|
||||||
if (it != audit_target_arg_indices.end()) {
|
if (it != audit_target_arg_indices.end()) {
|
||||||
for (const Py_ssize_t index : it->second) {
|
for (const Py_ssize_t index : it->second) {
|
||||||
std::string value = python_str(tuple_item(args, index));
|
// None is an argument left out (sendmsg without an address), not a target named "None".
|
||||||
|
PyObject* const item = tuple_item(args, index);
|
||||||
|
if (item == Py_None)
|
||||||
|
continue;
|
||||||
|
std::string value = python_str(item);
|
||||||
if (!value.empty()) {
|
if (!value.empty()) {
|
||||||
targets.push_back(std::move(value));
|
targets.push_back(std::move(value));
|
||||||
break;
|
break;
|
||||||
@@ -778,8 +862,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
|
// The saved list that grants an event: its category's target list, or the event-grant list for
|
||||||
|
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
|
||||||
|
// os.fork grant is not an os.exec of a command named "os.fork").
|
||||||
|
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
|
||||||
{
|
{
|
||||||
|
if (by_event)
|
||||||
|
return &permissions.events;
|
||||||
|
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||||
@@ -792,6 +882,13 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, Plugi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
|
||||||
|
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
|
||||||
|
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
|
||||||
|
{
|
||||||
|
return std::string(category_name(category)) + ":" + event_name;
|
||||||
|
}
|
||||||
|
|
||||||
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
||||||
{
|
{
|
||||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||||
@@ -902,22 +999,25 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
|
|||||||
|
|
||||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
// 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.
|
// freshly because the async prompt outlives the caller's stack copy of it.
|
||||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets)
|
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
||||||
{
|
{
|
||||||
PluginInstallState state;
|
PluginInstallState state;
|
||||||
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions);
|
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||||
if (!permission_list)
|
if (!permission_list)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
for (const auto& target : targets)
|
for (const auto& key : keys)
|
||||||
persist_permission(plugin_key, state, *permission_list, target);
|
persist_permission(plugin_key, state, *permission_list, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Requests permission for an audited event, returning true when it is already granted or the user
|
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||||
// approves an inline prompt.
|
|
||||||
|
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
||||||
|
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
||||||
|
// no target (by_event), which the prompt does not show as a target.
|
||||||
//
|
//
|
||||||
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
||||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||||
@@ -929,56 +1029,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c
|
|||||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||||
// re-prompting.
|
// re-prompting.
|
||||||
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
||||||
const std::string& event_name, const std::vector<std::string>& unresolved)
|
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
|
||||||
{
|
{
|
||||||
|
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
|
||||||
|
|
||||||
|
if (s_permission_prompt_for_testing)
|
||||||
|
return s_permission_prompt_for_testing(event_name, shown);
|
||||||
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (wxIsMainThread())
|
if (wxIsMainThread())
|
||||||
return prompt_for_targets(category, plugin_name, event_name, unresolved);
|
return prompt_for_targets(category, plugin_name, event_name, shown);
|
||||||
|
|
||||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() {
|
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
return;
|
return;
|
||||||
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
|
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
||||||
persist_grant(plugin_key, category, unresolved);
|
persist_grant(plugin_key, category, by_event, keys);
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
|
||||||
|
// otherwise the user is asked and a Yes is saved.
|
||||||
int decide_audited_event(PluginAuditManager& mgr,
|
int decide_audited_event(PluginAuditManager& mgr,
|
||||||
PluginInstallState& state,
|
PluginInstallState& state,
|
||||||
const std::string& plugin_key,
|
const std::string& plugin_key,
|
||||||
const std::string& plugin_name,
|
const std::string& plugin_name,
|
||||||
const std::string& event_name,
|
const std::string& event_name,
|
||||||
AuditEventCategory category,
|
AuditEventCategory category,
|
||||||
const std::vector<std::string>& targets,
|
const std::vector<std::string>& targets)
|
||||||
std::vector<std::string>* permission_list,
|
|
||||||
const std::vector<std::string>& call_site_ids)
|
|
||||||
{
|
{
|
||||||
std::vector<std::string> unresolved = targets;
|
const bool by_event = targets.empty();
|
||||||
|
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
|
||||||
|
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||||
|
|
||||||
|
std::vector<std::string> unresolved = keys;
|
||||||
if (permission_list) {
|
if (permission_list) {
|
||||||
unresolved.clear();
|
unresolved.clear();
|
||||||
for (const auto& target : targets)
|
for (const auto& key : keys)
|
||||||
if (!has_permission(*permission_list, target))
|
if (!has_permission(*permission_list, key))
|
||||||
unresolved.push_back(target);
|
unresolved.push_back(key);
|
||||||
if (!targets.empty() && unresolved.empty())
|
if (unresolved.empty())
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved))
|
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
||||||
|
// where no dialog may open: what they do must already be granted.
|
||||||
|
if (mgr.releasing_frames())
|
||||||
|
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||||
|
|
||||||
|
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
|
||||||
return report_denied(mgr, event_name, {false, "audit permission required"});
|
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||||
|
|
||||||
if (permission_list)
|
if (permission_list)
|
||||||
for (const auto& target : unresolved)
|
for (const auto& key : unresolved)
|
||||||
persist_permission(plugin_key, state, *permission_list, target);
|
persist_permission(plugin_key, state, *permission_list, key);
|
||||||
|
|
||||||
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Judges an audited event raised while a plugin context is active.
|
||||||
|
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
|
||||||
|
{
|
||||||
|
const bool fs_category = is_fs_category(event_type);
|
||||||
|
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
|
||||||
|
|
||||||
|
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||||
|
// unconditional block: checked before the running-operation check below, so no granted
|
||||||
|
// operation can launder access to one, and before the allowed-root shortcut further down,
|
||||||
|
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
|
||||||
|
// underneath it reachable.
|
||||||
|
if (fs_category) {
|
||||||
|
for (const auto& target : targets) {
|
||||||
|
if (mgr.is_denied_path(boost::filesystem::path(target)))
|
||||||
|
return report_denied(mgr, event_name, {false, "denied path"}, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nested event of an operation already granted, e.g. socket.connect inside an approved
|
||||||
|
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
|
||||||
|
// redirect or a nested urlopen() inside a running request is not covered by it.
|
||||||
|
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||||
|
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||||
|
// installed/system profile cache), the read-only bundled resources folder, or a per-call
|
||||||
|
// scoped root such as the current G-code folder -- is part of the plugin system's normal
|
||||||
|
// workflow and does not need a prompt.
|
||||||
|
if (fs_category && !targets.empty()) {
|
||||||
|
const bool is_write = event_type != AuditEventCategory::FsRead;
|
||||||
|
const bool all_inside_allowed_root =
|
||||||
|
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
|
||||||
|
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
|
||||||
|
});
|
||||||
|
if (all_inside_allowed_root)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginInstallState state;
|
||||||
|
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
|
||||||
|
if (!have_install_state) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
|
||||||
|
|
||||||
|
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
|
||||||
|
|
||||||
|
// A granted operation covers its nested events while it runs. A targetless grant names no
|
||||||
|
// operation, so it covers nothing else.
|
||||||
|
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
|
||||||
|
mgr.record_root_frame();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace PluginAuditDetail
|
} // namespace PluginAuditDetail
|
||||||
|
|
||||||
|
void PluginAuditManager::record_root_frame()
|
||||||
|
{
|
||||||
|
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
|
||||||
|
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
|
||||||
|
return;
|
||||||
|
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
|
||||||
|
return; // glob.glob and glob.glob/2 share a frame
|
||||||
|
Py_INCREF(frame);
|
||||||
|
m_root_frames.push_back(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::covered_by_root_frame() const
|
||||||
|
{
|
||||||
|
if (m_root_frames.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only stdlib frames may sit between the event and the operation: plugin or library code
|
||||||
|
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
|
||||||
|
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
|
||||||
|
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
|
||||||
|
return true;
|
||||||
|
if (!PluginAuditDetail::is_stdlib_frame(frame))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
|
||||||
|
|
||||||
|
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
|
||||||
|
{
|
||||||
|
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
|
||||||
|
if (boost::algorithm::starts_with(filename, "<frozen "))
|
||||||
|
return true;
|
||||||
|
if (filename.empty() || filename.front() == '<')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Both sides are resolved: co_filename follows the Python home as it was given, which may
|
||||||
|
// be a symlink.
|
||||||
|
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
|
||||||
|
if (inserted) {
|
||||||
|
const boost::filesystem::path file(filename);
|
||||||
|
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
|
||||||
|
return std::any_of(dirs.begin(), dirs.end(),
|
||||||
|
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
|
||||||
|
};
|
||||||
|
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
|
||||||
|
}
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
|
||||||
|
{
|
||||||
|
if (frames.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A finalizer's own denial must neither replace the PermissionError of the event being
|
||||||
|
// decided nor leave a stale denial behind for the next call.
|
||||||
|
PyObject* const error = PyErr_GetRaisedException();
|
||||||
|
const bool denial_pending = m_audit_denial_pending;
|
||||||
|
const bool has_violation = m_has_last_violation;
|
||||||
|
const AuditViolation violation = m_last_violation;
|
||||||
|
|
||||||
|
std::vector<PyObject*> released;
|
||||||
|
released.swap(frames);
|
||||||
|
++m_releasing_frames;
|
||||||
|
for (PyObject* frame : released)
|
||||||
|
Py_DECREF(frame);
|
||||||
|
--m_releasing_frames;
|
||||||
|
|
||||||
|
m_audit_denial_pending = denial_pending;
|
||||||
|
m_has_last_violation = has_violation;
|
||||||
|
m_last_violation = violation;
|
||||||
|
PyErr_SetRaisedException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
|
||||||
|
{
|
||||||
|
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||||
{
|
{
|
||||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||||
@@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
|||||||
if (event_type == AuditEventCategory::None)
|
if (event_type == AuditEventCategory::None)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
const bool fs_category = is_fs_category(event_type);
|
// Root frames whose operation has returned are let go once this event is decided, so that a
|
||||||
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
|
// finalizer they run re-enters a hook whose held frames are already consistent.
|
||||||
|
std::vector<PyObject*> returned_frames;
|
||||||
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
|
||||||
// unconditional block: checked before the ancestor-cascade check below, so a cascade
|
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
|
||||||
// approval recorded for an unrelated action can never launder access to one, and before
|
mgr->release_frames(returned_frames);
|
||||||
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
|
return result;
|
||||||
// folder) cannot make a denied path underneath it reachable.
|
|
||||||
if (fs_category) {
|
|
||||||
for (const auto& target : targets) {
|
|
||||||
if (mgr->is_denied_path(boost::filesystem::path(target)))
|
|
||||||
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PluginDescriptor plugin_descriptor;
|
|
||||||
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
|
||||||
|
|
||||||
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
|
|
||||||
// was already approved, socket.connect won't trigger another permission dialog.
|
|
||||||
const std::vector<std::string> call_site_ids =
|
|
||||||
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
|
|
||||||
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
|
||||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
|
||||||
// installed/system profile cache), the read-only bundled resources folder, or a per-call
|
|
||||||
// scoped root such as the current G-code folder -- is part of the plugin system's normal
|
|
||||||
// workflow and does not need a prompt.
|
|
||||||
if (fs_category && !targets.empty()) {
|
|
||||||
const bool is_write = event_type != AuditEventCategory::FsRead;
|
|
||||||
const bool all_inside_allowed_root =
|
|
||||||
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
|
|
||||||
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
|
|
||||||
});
|
|
||||||
if (all_inside_allowed_root)
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
PluginInstallState state;
|
|
||||||
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
|
|
||||||
if (!have_install_state) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin();
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
|
|
||||||
|
|
||||||
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
|
|
||||||
|
|
||||||
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
|
|
||||||
event_type, targets, permission_list, call_site_ids);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginAuditManager::install_hook()
|
void PluginAuditManager::install_hook()
|
||||||
{
|
{
|
||||||
|
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
|
||||||
|
PluginAuditDetail::collect_stdlib_dirs();
|
||||||
|
|
||||||
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
|
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
|
||||||
// covers the plugin system's own workflow needs -- each plugin's storage folder
|
// covers the plugin system's own workflow needs -- each plugin's storage folder
|
||||||
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
|
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
// Via pybind11 so this file requests the same python3xx.lib as everything else.
|
||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <pybind11/conduit/wrap_include_python_h.h>
|
#include <pybind11/conduit/wrap_include_python_h.h>
|
||||||
|
#include <functional>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
@@ -144,15 +143,26 @@ public:
|
|||||||
void clear_last_violation();
|
void clear_last_violation();
|
||||||
bool last_violation(AuditViolation& violation) const;
|
bool last_violation(AuditViolation& violation) const;
|
||||||
|
|
||||||
// --- call-site cascade cache ---
|
// --- operation-root frames ---
|
||||||
// A single plugin action often fires several nested CPython audit events as it passes
|
// One stdlib operation fires several nested audit events as it passes through lower layers:
|
||||||
// through stdlib layers (urllib.request calling http.client calling socket, for example).
|
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
|
||||||
// Once the user approves one event, every stdlib frame still on the stack for that call
|
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
|
||||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
// is held, and a later event whose stack reaches that frame through stdlib frames only is
|
||||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
|
||||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
// library code the operation calls back into is judged on its own. A frame is let go once it
|
||||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
|
||||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
|
||||||
|
void record_root_frame();
|
||||||
|
bool covered_by_root_frame() const;
|
||||||
|
bool releasing_frames() const;
|
||||||
|
|
||||||
|
// True when code from `filename` (a code object's co_filename) is the interpreter's own
|
||||||
|
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
|
||||||
|
static bool is_stdlib_file(const std::string& filename);
|
||||||
|
|
||||||
|
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
|
||||||
|
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
|
||||||
|
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
|
||||||
|
|
||||||
bool verbose_events = true;
|
bool verbose_events = true;
|
||||||
|
|
||||||
@@ -163,24 +173,32 @@ private:
|
|||||||
|
|
||||||
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
||||||
|
|
||||||
|
// Drops the references in `frames`, keeping the caller's error and denial state intact.
|
||||||
|
void release_frames(std::vector<PyObject*>& frames);
|
||||||
|
|
||||||
static thread_local std::string m_current_plugin_key;
|
static thread_local std::string m_current_plugin_key;
|
||||||
static thread_local std::string m_current_capability_name;
|
static thread_local std::string m_current_capability_name;
|
||||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||||
|
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||||
|
static thread_local int m_releasing_frames;
|
||||||
static thread_local bool m_audit_denial_pending;
|
static thread_local bool m_audit_denial_pending;
|
||||||
static thread_local bool m_has_last_violation;
|
static thread_local bool m_has_last_violation;
|
||||||
static thread_local AuditViolation m_last_violation;
|
static thread_local AuditViolation m_last_violation;
|
||||||
|
|
||||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
// mutable: is_denied_filename() is a const query that must lock.
|
||||||
mutable std::mutex m_mutex;
|
mutable std::mutex m_mutex;
|
||||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||||
std::vector<std::string> m_denied_filenames;
|
std::vector<std::string> m_denied_filenames;
|
||||||
std::vector<std::string> m_denied_path_keywords;
|
std::vector<std::string> m_denied_path_keywords;
|
||||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||||
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
||||||
// single capability.
|
// single capability.
|
||||||
|
//
|
||||||
|
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
|
||||||
|
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
|
||||||
|
// the scope after the GIL guard, to be destroyed before it.
|
||||||
class ScopedPluginAuditContext
|
class ScopedPluginAuditContext
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -194,9 +212,12 @@ public:
|
|||||||
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
std::string m_plugin_key;
|
||||||
|
std::string m_capability;
|
||||||
std::string m_previous_id;
|
std::string m_previous_id;
|
||||||
std::string m_previous_capability;
|
std::string m_previous_capability;
|
||||||
std::vector<AllowedRoot> m_previous_scoped_roots;
|
std::vector<AllowedRoot> m_previous_scoped_roots;
|
||||||
|
std::vector<PyObject*> m_previous_root_frames;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
|
|||||||
read_string_list("network_socket", parsed.permissions.network_socket);
|
read_string_list("network_socket", parsed.permissions.network_socket);
|
||||||
read_string_list("process", parsed.permissions.process);
|
read_string_list("process", parsed.permissions.process);
|
||||||
read_string_list("threading", parsed.permissions.threading);
|
read_string_list("threading", parsed.permissions.threading);
|
||||||
|
read_string_list("events", parsed.permissions.events);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.contains("enabled") && state["enabled"].is_boolean())
|
if (state.contains("enabled") && state["enabled"].is_boolean())
|
||||||
@@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
|||||||
{"network_socket", state.permissions.network_socket},
|
{"network_socket", state.permissions.network_socket},
|
||||||
{"process", state.permissions.process},
|
{"process", state.permissions.process},
|
||||||
{"threading", state.permissions.threading},
|
{"threading", state.permissions.threading},
|
||||||
|
{"events", state.permissions.events},
|
||||||
};
|
};
|
||||||
|
|
||||||
nlohmann::json capabilities = nlohmann::json::array();
|
nlohmann::json capabilities = nlohmann::json::array();
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ struct PluginPermissions
|
|||||||
std::vector<std::string> network_socket;
|
std::vector<std::string> network_socket;
|
||||||
std::vector<std::string> process;
|
std::vector<std::string> process;
|
||||||
std::vector<std::string> threading;
|
std::vector<std::string> threading;
|
||||||
|
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
|
||||||
|
std::vector<std::string> events;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct PluginInstallState {
|
struct PluginInstallState {
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ public:
|
|||||||
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
|
||||||
nlohmann::json get_default_config() const override
|
nlohmann::json get_default_config() const override
|
||||||
{
|
{
|
||||||
|
pybind11::gil_scoped_acquire gil;
|
||||||
ORCA_PY_AUDIT_SCOPE();
|
ORCA_PY_AUDIT_SCOPE();
|
||||||
try {
|
try {
|
||||||
pybind11::gil_scoped_acquire gil;
|
|
||||||
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
||||||
if (!override)
|
if (!override)
|
||||||
return Base::get_default_config();
|
return Base::get_default_config();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
#include "GeneratedConfig.hpp"
|
#include "GeneratedConfig.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
#include "PythonPluginBridge.hpp"
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
@@ -665,11 +664,6 @@ bool PythonInterpreter::initialize()
|
|||||||
// background-thread exceptions) to <data_dir>/log/python_*.log.
|
// background-thread exceptions) to <data_dir>/log/python_*.log.
|
||||||
install_python_stderr_redirect();
|
install_python_stderr_redirect();
|
||||||
|
|
||||||
// Attribute plugin-spawned Python threads to the plugin/capability that created them.
|
|
||||||
// Must run while the GIL is still held here, after the audit hook is in place and
|
|
||||||
// before the GIL is released below.
|
|
||||||
install_python_thread_audit_shim();
|
|
||||||
|
|
||||||
// Release the GIL so other threads can acquire it via PyGILState_Ensure.
|
// Release the GIL so other threads can acquire it via PyGILState_Ensure.
|
||||||
// Without this, calls from background threads will block trying to acquire the GIL.
|
// Without this, calls from background threads will block trying to acquire the GIL.
|
||||||
m_main_thread_state = PyEval_SaveThread();
|
m_main_thread_state = PyEval_SaveThread();
|
||||||
|
|||||||
@@ -603,93 +603,6 @@ request aborts the plugin load.)pbdoc");
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void install_python_thread_audit_shim()
|
|
||||||
{
|
|
||||||
// Runs with the GIL held from PythonInterpreter::initialize(). CPython's own thread bootstrap
|
|
||||||
// calls the target with no knowledge of the audit context that created it: the calling
|
|
||||||
// thread is the only place the plugin key and capability are still available. Capture them
|
|
||||||
// here, then re-establish the same ScopedPluginAuditContext inside the spawned thread before
|
|
||||||
// the plugin body runs. The identity lives only in this C++ closure -- no Python-visible
|
|
||||||
// setter is added.
|
|
||||||
try {
|
|
||||||
py::object thread_module = py::module_::import("_thread");
|
|
||||||
|
|
||||||
const int installed = PyObject_HasAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed");
|
|
||||||
if (installed == 1) {
|
|
||||||
BOOST_LOG_TRIVIAL(debug) << "[AUDIT] Python thread attribution shim already installed";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (installed < 0)
|
|
||||||
PyErr_Clear();
|
|
||||||
|
|
||||||
py::object original = py::reinterpret_steal<py::object>(
|
|
||||||
PyObject_GetAttrString(thread_module.ptr(), "start_new_thread"));
|
|
||||||
if (!original || !PyCallable_Check(original.ptr())) {
|
|
||||||
PyErr_Clear();
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] _thread.start_new_thread is missing or not callable; "
|
|
||||||
"plugin threads will run without audit attribution";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
py::object shim = py::cpp_function(
|
|
||||||
[original](py::object function, py::object args, py::object kwargs) -> py::object {
|
|
||||||
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
|
|
||||||
if (plugin_key.empty()) {
|
|
||||||
// Not called from a plugin: preserve the stock implementation untouched.
|
|
||||||
return kwargs.is_none() ? original(function, args) : original(function, args, kwargs);
|
|
||||||
}
|
|
||||||
const std::string capability = PluginAuditManager::instance().current_capability();
|
|
||||||
|
|
||||||
py::object guarded = py::cpp_function(
|
|
||||||
[function, plugin_key, capability](py::args call_args, py::kwargs call_kwargs) -> py::object {
|
|
||||||
ScopedPluginAuditContext audit_scope(plugin_key, capability);
|
|
||||||
PyObject* result = PyObject_Call(function.ptr(), call_args.ptr(), call_kwargs.ptr());
|
|
||||||
if (result == nullptr)
|
|
||||||
throw py::error_already_set();
|
|
||||||
return py::reinterpret_steal<py::object>(result);
|
|
||||||
});
|
|
||||||
return kwargs.is_none() ? original(guarded, args) : original(guarded, args, kwargs);
|
|
||||||
},
|
|
||||||
py::arg("function"), py::arg("args"), py::arg("kwargs") = py::none());
|
|
||||||
|
|
||||||
if (PyObject_SetAttrString(thread_module.ptr(), "start_new_thread", shim.ptr()) < 0) {
|
|
||||||
PyErr_Clear();
|
|
||||||
BOOST_LOG_TRIVIAL(error)
|
|
||||||
<< "[AUDIT] Failed to install Python thread attribution shim on _thread.start_new_thread";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (PyObject_SetAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed", Py_True) < 0)
|
|
||||||
PyErr_Clear();
|
|
||||||
|
|
||||||
// threading.py caches the function at import time (as `_start_new_thread` in 3.12, and as
|
|
||||||
// `start_new_thread` in older versions). Patch the cached reference too when the module is
|
|
||||||
// already loaded; otherwise its Thread.start() would keep calling the unwrapped original.
|
|
||||||
py::object sys_modules = py::module_::import("sys").attr("modules");
|
|
||||||
PyObject* threading_module = PyDict_GetItemString(sys_modules.ptr(), "threading");
|
|
||||||
if (threading_module && threading_module != Py_None) {
|
|
||||||
const char* cached_names[] = {"_start_new_thread", "start_new_thread"};
|
|
||||||
for (const char* cached_name : cached_names) {
|
|
||||||
const int has_cached_name = PyObject_HasAttrString(threading_module, cached_name);
|
|
||||||
if (has_cached_name < 0) {
|
|
||||||
PyErr_Clear();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (has_cached_name != 1)
|
|
||||||
continue;
|
|
||||||
if (PyObject_SetAttrString(threading_module, cached_name, shim.ptr()) < 0) {
|
|
||||||
PyErr_Clear();
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Failed to update threading." << cached_name
|
|
||||||
<< " with the Python thread attribution shim";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Python thread attribution shim installed";
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install Python thread attribution shim: " << ex.what();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|
||||||
#ifdef ORCA_PYTHON_STUBGEN_MODULE
|
#ifdef ORCA_PYTHON_STUBGEN_MODULE
|
||||||
|
|||||||
@@ -13,15 +13,6 @@ namespace Slic3r { class PluginCapabilityInterface; }
|
|||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
// Install the thread-attribution shim: wraps `_thread.start_new_thread` (and the reference
|
|
||||||
// cached by an already-imported `threading` module) so a thread spawned from inside a plugin
|
|
||||||
// call runs with that call's plugin key/capability audit scope. The captured identity is held
|
|
||||||
// in a private C++ wrapper; nothing is exposed to Python that could set it.
|
|
||||||
//
|
|
||||||
// Call with the GIL held, after PluginAuditManager::install_hook() and before the interpreter
|
|
||||||
// releases the GIL. Safe to call more than once per interpreter.
|
|
||||||
void install_python_thread_audit_shim();
|
|
||||||
|
|
||||||
// One materialized capability returned from finalize_plugin_capture: the C++ instance
|
// One materialized capability returned from finalize_plugin_capture: the C++ instance
|
||||||
// and its resolved get_name() (cached while the GIL was held).
|
// and its resolved get_name() (cached while the GIL was held).
|
||||||
struct CapturedCapability
|
struct CapturedCapability
|
||||||
|
|||||||
+1
-1
@@ -282,11 +282,11 @@ public:
|
|||||||
int request_bind_ticket(std::string* ticket) override
|
int request_bind_ticket(std::string* ticket) override
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
ORCA_PY_AUDIT_SCOPE();
|
|
||||||
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
|
||||||
::Slic3r::PythonGILState gil;
|
::Slic3r::PythonGILState gil;
|
||||||
if (!gil)
|
if (!gil)
|
||||||
throw std::runtime_error("Python interpreter is shutting down");
|
throw std::runtime_error("Python interpreter is shutting down");
|
||||||
|
ORCA_PY_AUDIT_SCOPE();
|
||||||
pybind11::function override =
|
pybind11::function override =
|
||||||
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
|
||||||
if (!override)
|
if (!override)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/catch_message.hpp>
|
||||||
#include "test_helpers.hpp"
|
#include "test_helpers.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -17,15 +18,48 @@
|
|||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
using namespace Slic3r::Test;
|
using namespace Slic3r::Test;
|
||||||
|
|
||||||
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
|
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
|
||||||
// fan-off command is emitted.
|
|
||||||
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
|
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
|
||||||
{
|
{
|
||||||
|
const int fan_off_layers = 5;
|
||||||
const std::string gcode = slice({ cube(20) }, {
|
const std::string gcode = slice({ cube(20) }, {
|
||||||
{ "cooling", true },
|
{ "close_fan_the_first_x_layers", fan_off_layers },
|
||||||
{ "close_fan_the_first_x_layers", 5 },
|
{ "full_fan_speed_layer", 0 },
|
||||||
|
{ "fan_min_speed", 50 },
|
||||||
|
{ "fan_max_speed", 100 },
|
||||||
|
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
|
||||||
|
{ "layer_height", 0.2 },
|
||||||
|
{ "initial_layer_print_height", 0.2 },
|
||||||
});
|
});
|
||||||
CHECK(gcode.find("M106 S0") != std::string::npos);
|
|
||||||
|
// Record, for each layer, whether any extrusion was printed with the part fan running.
|
||||||
|
std::vector<bool> extruded_with_fan;
|
||||||
|
int layer = -1;
|
||||||
|
bool fan_on = false;
|
||||||
|
std::istringstream input(gcode);
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(input, line)) {
|
||||||
|
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
|
||||||
|
++layer;
|
||||||
|
extruded_with_fan.push_back(false);
|
||||||
|
} else if (line.rfind("M107", 0) == 0) {
|
||||||
|
fan_on = false;
|
||||||
|
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
|
||||||
|
const size_t s = line.find(" S");
|
||||||
|
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
|
||||||
|
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
|
||||||
|
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
|
||||||
|
extruded_with_fan[layer] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 20 mm cube at 0.2 mm layers has 100 layers.
|
||||||
|
REQUIRE(extruded_with_fan.size() == 100);
|
||||||
|
for (int i = 0; i < fan_off_layers; ++i) {
|
||||||
|
CAPTURE(i);
|
||||||
|
CHECK_FALSE(extruded_with_fan[i]);
|
||||||
|
}
|
||||||
|
CHECK(extruded_with_fan[fan_off_layers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cooling pass resolves and strips its internal speed placeholders; none leak into
|
// The cooling pass resolves and strips its internal speed placeholders; none leak into
|
||||||
|
|||||||
@@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
|||||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
|
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
|
||||||
REQUIRE(paths.size() == 1); // one continuous path
|
REQUIRE(paths.size() == 1); // one continuous path
|
||||||
|
|
||||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
|
||||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
// and a single line crosses the middle, ending within half a spacing of the edges.
|
||||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
|
||||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
|
||||||
|
|
||||||
test_set.clear();
|
test_set.clear();
|
||||||
}
|
}
|
||||||
@@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
|||||||
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
|
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
|
||||||
REQUIRE(paths.size() == 1); // one continuous path
|
REQUIRE(paths.size() == 1); // one continuous path
|
||||||
|
|
||||||
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
|
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
|
||||||
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
|
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
|
||||||
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
|
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
|
||||||
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("Rotated Square") {
|
SECTION("Rotated Square") {
|
||||||
@@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
|||||||
fill_params.density = 1.0;
|
fill_params.density = 1.0;
|
||||||
filler->spacing = flow.spacing();
|
filler->spacing = flow.spacing();
|
||||||
|
|
||||||
for (auto angle : { 0.0, 45.0}) {
|
for (auto angle : { 0.0, PI / 4.0 }) {
|
||||||
surface.expolygon.rotate(angle, Point(0,0));
|
surface.expolygon.rotate(angle, Point(0,0));
|
||||||
|
filler->bounding_box = get_extents(surface.expolygon.contour);
|
||||||
Polylines paths = filler->fill_surface(&surface, fill_params);
|
Polylines paths = filler->fill_surface(&surface, fill_params);
|
||||||
REQUIRE(paths.size() == 1);
|
REQUIRE(paths.size() == 1);
|
||||||
}
|
}
|
||||||
@@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
|
|||||||
};
|
};
|
||||||
Slic3r::ExPolygon expolygon(points);
|
Slic3r::ExPolygon expolygon(points);
|
||||||
|
|
||||||
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
|
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
|
|||||||
config.set_deserialize_strict({
|
config.set_deserialize_strict({
|
||||||
{ "top_shell_layers", 2 },
|
{ "top_shell_layers", 2 },
|
||||||
{ "bottom_shell_layers", 1 },
|
{ "bottom_shell_layers", 1 },
|
||||||
{ "layer_height", 0.25 }, // get a known number of layers
|
// Shell counts alone decide which layers are solid.
|
||||||
|
{ "top_shell_thickness", 0 },
|
||||||
|
{ "bottom_shell_thickness", 0 },
|
||||||
|
{ "sparse_infill_density", "20%" },
|
||||||
|
{ "layer_height", 0.25 }, // 80 layers: 0..79
|
||||||
{ "initial_layer_print_height", 0.25 }
|
{ "initial_layer_print_height", 0.25 }
|
||||||
});
|
});
|
||||||
Slic3r::Print print;
|
Slic3r::Print print;
|
||||||
Slic3r::Model model;
|
Slic3r::Model model;
|
||||||
Slic3r::Test::init_print({cube(20)}, print, model, config);
|
Slic3r::Test::init_print({cube(20)}, print, model, config);
|
||||||
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
|
// True when the layer has fill surfaces and all of them are solid.
|
||||||
// and one solid bottom layer (0).
|
auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||||
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
|
||||||
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
|
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
|
||||||
// iterate over all of the regions in the layer
|
bool has_fill = false;
|
||||||
for (const LayerRegion *region : layer.regions()) {
|
for (const LayerRegion *region : layer.regions())
|
||||||
// for each region, iterate over the fill surfaces
|
for (const Surface &surface : region->fill_surfaces.surfaces) {
|
||||||
|
if (! surface.is_solid())
|
||||||
|
return false;
|
||||||
|
has_fill = true;
|
||||||
|
}
|
||||||
|
return has_fill;
|
||||||
|
};
|
||||||
|
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
|
||||||
|
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||||
|
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
|
||||||
|
for (const LayerRegion *region : layer.regions())
|
||||||
for (const Surface &surface : region->fill_surfaces.surfaces)
|
for (const Surface &surface : region->fill_surfaces.surfaces)
|
||||||
CHECK(surface.is_solid());
|
if (! surface.is_solid())
|
||||||
}
|
return true;
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
print.process();
|
print.process();
|
||||||
test_is_solid_infill(0, 0); // should be solid
|
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
|
||||||
test_is_solid_infill(0, 79); // should be solid
|
REQUIRE(is_solid_infill(0, 0));
|
||||||
test_is_solid_infill(0, 78); // should be solid
|
REQUIRE(is_solid_infill(0, 79));
|
||||||
|
REQUIRE(is_solid_infill(0, 78));
|
||||||
|
REQUIRE(has_sparse_infill(0, 77));
|
||||||
WHEN("Model is re-sliced with top_shell_layers == 3") {
|
WHEN("Model is re-sliced with top_shell_layers == 3") {
|
||||||
config.set("top_shell_layers", 3);
|
config.set("top_shell_layers", 3);
|
||||||
print.apply(model, config);
|
print.apply(model, config);
|
||||||
print.process();
|
print.process();
|
||||||
THEN("Print object does not have 0 solid bottom layers.") {
|
THEN("Print object does not have 0 solid bottom layers.") {
|
||||||
test_is_solid_infill(0, 0);
|
CHECK(is_solid_infill(0, 0));
|
||||||
}
|
}
|
||||||
AND_THEN("Print object has 3 top solid layers") {
|
AND_THEN("Print object has 3 top solid layers") {
|
||||||
test_is_solid_infill(0, 79);
|
CHECK(is_solid_infill(0, 79));
|
||||||
test_is_solid_infill(0, 78);
|
CHECK(is_solid_infill(0, 78));
|
||||||
test_is_solid_infill(0, 77);
|
CHECK(is_solid_infill(0, 77));
|
||||||
|
CHECK(has_sparse_infill(0, 76));
|
||||||
|
}
|
||||||
|
AND_THEN("Middle layers keep sparse infill") {
|
||||||
|
CHECK(has_sparse_infill(0, 40));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
|
|||||||
{
|
{
|
||||||
const std::string gcode = slice({ cube(20) }, {
|
const std::string gcode = slice({ cube(20) }, {
|
||||||
{ "gcode_skip_config_block", true },
|
{ "gcode_skip_config_block", true },
|
||||||
{ "gcode_comments", true },
|
|
||||||
});
|
});
|
||||||
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
|
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
|
||||||
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
|
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
|
||||||
CHECK(gcode.find("; layer_height =") == std::string::npos);
|
CHECK(gcode.find("; layer_height =") == std::string::npos);
|
||||||
CHECK(gcode.find("; fill_density =") == std::string::npos);
|
CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
|
||||||
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
|
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
|
||||||
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
|
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
|
||||||
|
|
||||||
|
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
|
||||||
|
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
|
||||||
|
CHECK(with_block.find("; layer_height =") != std::string::npos);
|
||||||
|
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
|
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers.hpp>
|
||||||
#include "libslic3r/TriangleMesh.hpp"
|
#include "libslic3r/TriangleMesh.hpp"
|
||||||
#include "libslic3r/TriangleMeshSlicer.hpp"
|
#include "libslic3r/TriangleMeshSlicer.hpp"
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
@@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
|
|||||||
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
|
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
|
||||||
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
|
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
|
||||||
|
|
||||||
auto cube = make_cube();
|
TriangleMesh mesh(vertices, facets);
|
||||||
|
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
|
||||||
|
const double wall_area = 50. * 5.;
|
||||||
|
const double wall_block_area = wall_area + 20. * 15.;
|
||||||
|
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
|
||||||
WHEN(" a top tangent plane is sliced") {
|
WHEN(" a top tangent plane is sliced") {
|
||||||
// At Z = 10 we have a top horizontal surface.
|
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
|
||||||
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
|
|
||||||
THEN( "its area is included") {
|
THEN( "its area is included") {
|
||||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
REQUIRE(slices.at(0).size() == 1);
|
||||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
REQUIRE(slices.at(1).size() == 1);
|
||||||
|
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||||
|
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN(" a model that has been transformed is sliced") {
|
WHEN(" a model that has been transformed is sliced") {
|
||||||
cube.mirror_z();
|
mesh.mirror_z();
|
||||||
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
|
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
|
||||||
THEN( "it is sliced properly (mirrored bottom plane area is included)") {
|
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
|
||||||
REQUIRE(slices.at(0).at(0).area() > 0);
|
REQUIRE(slices.at(0).size() == 1);
|
||||||
REQUIRE(slices.at(1).at(0).area() > 0);
|
REQUIRE(slices.at(1).size() == 1);
|
||||||
|
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
|
||||||
|
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
|
||||||
|
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
|
|||||||
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
|
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
|
||||||
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
|
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
|
||||||
REQUIRE(result.elapsed_ms < 40000.0);
|
REQUIRE(result.elapsed_ms < 40000.0);
|
||||||
REQUIRE(result.flush_cost >= 0);
|
|
||||||
|
|
||||||
|
// Every case, feasible or not, must yield a usable grouping: each used filament is
|
||||||
|
// assigned to an existing nozzle.
|
||||||
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
|
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
|
||||||
|
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
|
||||||
|
for (unsigned int fil : used_filaments) {
|
||||||
|
INFO("Filament: " << fil);
|
||||||
|
REQUIRE(fil < result.filament_map.size());
|
||||||
|
CHECK(result.filament_map[fil] >= 0);
|
||||||
|
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
|
||||||
|
}
|
||||||
|
|
||||||
if (is_constraint_feasible(tc.context, used_filaments)) {
|
if (is_constraint_feasible(tc.context, used_filaments)) {
|
||||||
if (!result.constraints_ok) {
|
if (!result.constraints_ok) {
|
||||||
for (auto& v : result.violations)
|
for (auto& v : result.violations)
|
||||||
|
|||||||
@@ -1055,18 +1055,19 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
|
// A project without multi-nozzle metadata must load through the BBS importer and must not
|
||||||
// importer and must not fabricate a filament_volume_map.
|
// fabricate a filament_volume_map.
|
||||||
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
|
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
|
||||||
GIVEN("a project without any multi-nozzle metadata") {
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
std::vector<Preset*> project_presets;
|
||||||
Model model;
|
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||||
DynamicPrintConfig config;
|
Semver file_version;
|
||||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
|
||||||
PlateDataPtrs plates;
|
GIVEN("a legacy project with a bare model and no plate metadata") {
|
||||||
std::vector<Preset*> project_presets;
|
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
Model model;
|
||||||
Semver file_version;
|
DynamicPrintConfig config;
|
||||||
|
PlateDataPtrs plates;
|
||||||
|
|
||||||
WHEN("loaded through the BBS importer") {
|
WHEN("loaded through the BBS importer") {
|
||||||
bool loaded = false;
|
bool loaded = false;
|
||||||
@@ -1074,10 +1075,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]
|
|||||||
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
||||||
&file_version, nullptr,
|
&file_version, nullptr,
|
||||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||||
THEN("it does not crash and invents no per-filament volume map") {
|
THEN("the model loads") {
|
||||||
for (PlateData* p : plates) {
|
REQUIRE(loaded);
|
||||||
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
REQUIRE_FALSE(model.objects.empty());
|
||||||
}
|
}
|
||||||
|
release_PlateData_list(plates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GIVEN("a project with plate metadata but no filament volume map") {
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
store_painted_cube(temp.string());
|
||||||
|
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
|
||||||
|
Model model;
|
||||||
|
model.set_backup_path(backup_dir.string());
|
||||||
|
DynamicPrintConfig config;
|
||||||
|
PlateDataPtrs plates;
|
||||||
|
|
||||||
|
WHEN("loaded through the BBS importer") {
|
||||||
|
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
|
||||||
|
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
|
||||||
|
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||||
|
THEN("every plate loads without a per-filament volume map") {
|
||||||
|
REQUIRE(loaded);
|
||||||
|
REQUIRE_FALSE(plates.empty());
|
||||||
|
for (PlateData* p : plates)
|
||||||
|
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||||
}
|
}
|
||||||
release_PlateData_list(plates);
|
release_PlateData_list(plates);
|
||||||
}
|
}
|
||||||
@@ -2048,8 +2071,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
|
|||||||
REQUIRE(dst_model.model_info != nullptr);
|
REQUIRE(dst_model.model_info != nullptr);
|
||||||
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
|
||||||
|
|
||||||
// The value must parse back with every extended field intact.
|
// The loaded value must parse back with every extended field intact.
|
||||||
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
|
nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
|
||||||
REQUIRE(entries.is_array());
|
REQUIRE(entries.is_array());
|
||||||
REQUIRE(entries.size() == 1);
|
REQUIRE(entries.size() == 1);
|
||||||
REQUIRE(entries[0]["full"].get<bool>() == true);
|
REQUIRE(entries[0]["full"].get<bool>() == true);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
#include <BRepAdaptor_Surface.hxx>
|
#include <BRepAdaptor_Surface.hxx>
|
||||||
#include <TopExp_Explorer.hxx>
|
#include <TopExp_Explorer.hxx>
|
||||||
#include <TopoDS.hxx>
|
#include <TopoDS.hxx>
|
||||||
|
#include <BRepAlgoAPI_Common.hxx>
|
||||||
#include <BRepGProp.hxx>
|
#include <BRepGProp.hxx>
|
||||||
#include <GProp_GProps.hxx>
|
#include <GProp_GProps.hxx>
|
||||||
#include <BRepPrimAPI_MakeBox.hxx>
|
#include <BRepPrimAPI_MakeBox.hxx>
|
||||||
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
|
|||||||
|
|
||||||
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
|
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
|
||||||
{
|
{
|
||||||
std::string font = resources_dir().empty()
|
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
const std::string font =
|
||||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||||
{
|
REQUIRE(std::ifstream(font).good());
|
||||||
std::ifstream probe(font);
|
|
||||||
if (!probe.good()) {
|
|
||||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string charset =
|
const std::string charset =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
@@ -2853,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
|||||||
using Catch::Matchers::WithinAbs;
|
using Catch::Matchers::WithinAbs;
|
||||||
|
|
||||||
CadDocument doc;
|
CadDocument doc;
|
||||||
// Build a body so we have a face to reference for FaceAndDirection.
|
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
|
||||||
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
// direction, so the frame has to fall back on coordsys_x_hint.
|
||||||
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
|
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
|
||||||
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
|
||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.bodies.size() == 1);
|
REQUIRE(doc.bodies.size() == 1);
|
||||||
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
||||||
@@ -2865,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
|
|||||||
if (fn.z() > 0.9) { top_face = i; break; }
|
if (fn.z() > 0.9) { top_face = i; break; }
|
||||||
}
|
}
|
||||||
REQUIRE(top_face >= 0);
|
REQUIRE(top_face >= 0);
|
||||||
|
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
|
||||||
|
|
||||||
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
|
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
|
||||||
REQUIRE(cs >= 0);
|
REQUIRE(cs >= 0);
|
||||||
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
|
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
|
||||||
doc.features[cs].coordsys_body = 0;
|
doc.features[cs].coordsys_body = 0;
|
||||||
doc.features[cs].coordsys_face = top_face;
|
doc.features[cs].coordsys_face = top_face;
|
||||||
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
|
// Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
|
||||||
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
|
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
|
||||||
|
|
||||||
auto css = doc.resolve_datum_coordsys();
|
auto css = doc.resolve_datum_coordsys();
|
||||||
REQUIRE(css.size() == 1);
|
REQUIRE(css.size() == 1);
|
||||||
REQUIRE(css[0].error.empty());
|
REQUIRE(css[0].error.empty());
|
||||||
|
|
||||||
Vec3d X = css[0].x, Y = css[0].y;
|
const Vec3d X = css[0].x, Y = css[0].y;
|
||||||
// Orthonormality: each axis has unit length
|
// X is the hint projected into the face plane.
|
||||||
|
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
|
||||||
|
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
|
||||||
|
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
|
||||||
|
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
|
||||||
|
// Orthonormal, with Z the face normal and the frame right-handed.
|
||||||
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
|
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
|
||||||
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
|
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
|
||||||
// Pairwise dot products are ~0
|
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
|
||||||
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
|
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
|
||||||
// Z = X x Y (derived), also unit and perpendicular
|
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
|
||||||
Vec3d Z = X.cross(Y);
|
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
|
||||||
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
|
|
||||||
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
|
|
||||||
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
|
|
||||||
// Right-handedness: X x Y == Z
|
|
||||||
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
|
|
||||||
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
|
|
||||||
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
|
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
|
||||||
@@ -3917,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
|
|||||||
}
|
}
|
||||||
REQUIRE(top_face >= 0);
|
REQUIRE(top_face >= 0);
|
||||||
|
|
||||||
const size_t body_count_before = doc.bodies.size();
|
|
||||||
const double volume_before = doc.body_mass_properties(0).volume;
|
|
||||||
|
|
||||||
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
|
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
|
||||||
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
|
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
|
||||||
REQUIRE(appended == 4);
|
REQUIRE(appended == 4);
|
||||||
|
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
|
||||||
|
|
||||||
REQUIRE(doc.recompute());
|
SECTION("construction edges form no profile, so the extrude fails") {
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE_FALSE(doc.recompute());
|
||||||
REQUIRE(doc.bodies.size() == body_count_before);
|
REQUIRE_FALSE(doc.error.empty());
|
||||||
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
|
}
|
||||||
|
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
|
||||||
|
for (auto& e : doc.features[target].entities)
|
||||||
|
e.construction = false;
|
||||||
|
REQUIRE(doc.recompute());
|
||||||
|
REQUIRE(doc.error.empty());
|
||||||
|
REQUIRE(doc.bodies.size() == 2);
|
||||||
|
REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
|
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
|
||||||
@@ -4252,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
|
|||||||
REQUIRE(doc.features.empty()); // nothing half-read was left behind
|
REQUIRE(doc.features.empty()); // nothing half-read was left behind
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
|
||||||
{
|
{
|
||||||
using Catch::Matchers::WithinRel;
|
using Catch::Matchers::WithinRel;
|
||||||
using Catch::Matchers::WithinAbs;
|
using Catch::Matchers::WithinAbs;
|
||||||
@@ -4282,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
|||||||
}
|
}
|
||||||
|
|
||||||
CadDocument expected = make_golden_doc_v1();
|
CadDocument expected = make_golden_doc_v1();
|
||||||
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
|
|
||||||
// scope for the field-value assertions below, where "regenerate the fixture" is the
|
|
||||||
// one thing you must never do -- regenerating after a reorder bakes the corrupted
|
|
||||||
// layout in as the new golden and permanently disarms this test.
|
|
||||||
{
|
{
|
||||||
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
|
INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
|
||||||
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
|
"frozen in cad_recipe_v4.bin, so do not add or remove features there.");
|
||||||
REQUIRE(features.size() == expected.features.size());
|
REQUIRE(features.size() == expected.features.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failure BELOW this point means the on-disk serialization format changed: some field
|
// A failure BELOW this point means the on-disk serialization format changed: some field
|
||||||
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
|
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
|
||||||
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
|
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
|
||||||
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
|
// fix the field order instead.
|
||||||
|
|
||||||
// Field-by-field assertions against expected values.
|
// Field-by-field assertions against expected values.
|
||||||
// Every field set to a distinctive non-default literal must be checked here.
|
// Every field set to a distinctive non-default literal must be checked here.
|
||||||
@@ -4550,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
|
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
|
||||||
// Build expected document and try to recompute it.
|
// The golden tree is serialization coverage and does not recompute as a whole: these
|
||||||
// ponytail: the field-value layer above is the real tripwire;
|
// features fail on the geometry they get, so they are left off in both trees.
|
||||||
// this layer is a bonus sanity check on the full recompute path.
|
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
|
||||||
|
"GoldenProject"};
|
||||||
CadDocument exp_doc = make_golden_doc_v1();
|
CadDocument exp_doc = make_golden_doc_v1();
|
||||||
bool exp_ok = exp_doc.recompute();
|
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults
|
||||||
if (!exp_ok) {
|
// (a v4 thread, for one, is not major-nominal). The reference has to match that.
|
||||||
INFO("Expected document from make_golden_doc_v1() could not recompute "
|
const CadFeature def;
|
||||||
"(complex feature tree). Field-value checks above are sufficient.");
|
for (CadFeature& f : exp_doc.features) {
|
||||||
|
f.thread_major_nominal = def.thread_major_nominal;
|
||||||
|
f.pattern_inclusive = def.pattern_inclusive;
|
||||||
|
f.dressup_edges = def.dressup_edges;
|
||||||
|
f.text_string = def.text_string;
|
||||||
|
f.text_font = def.text_font;
|
||||||
|
f.text_height = def.text_height;
|
||||||
|
f.revolve_axis_entity = def.revolve_axis_entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
CadDocument doc;
|
CadDocument doc;
|
||||||
bool ser_ok = doc.deserialize_recipe(blob);
|
doc.deserialize_recipe(blob);
|
||||||
|
REQUIRE(doc.features.size() == exp_doc.features.size());
|
||||||
if (exp_ok && ser_ok) {
|
for (size_t i = 0; i < exp_doc.features.size(); ++i)
|
||||||
REQUIRE(doc.error.empty());
|
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
|
||||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
const bool exp_built = exp_doc.recompute();
|
||||||
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
INFO("reference error: " << exp_doc.error);
|
||||||
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
|
REQUIRE(exp_built);
|
||||||
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
|
const bool built = doc.recompute();
|
||||||
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
|
INFO("loaded error: " << doc.error);
|
||||||
|
REQUIRE(built);
|
||||||
|
REQUIRE_FALSE(exp_doc.bodies.empty());
|
||||||
|
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||||
|
for (size_t b = 0; b < doc.bodies.size(); ++b) {
|
||||||
|
INFO("body " << b);
|
||||||
|
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
|
||||||
|
TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
|
||||||
|
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
|
||||||
|
// Volume alone is blind to fields that only place a body, such as a transform.
|
||||||
|
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
|
||||||
|
for (int k = 0; k < 3; ++k) {
|
||||||
|
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
|
||||||
|
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
INFO("The golden recipe fixture was loaded and field-value checks passed.");
|
|
||||||
INFO("Recompute on the deserialized or expected document failed — this is");
|
|
||||||
INFO("expected for the extended golden fixture (many feature types coexist");
|
|
||||||
INFO("purely for serialization coverage). The field-value tripwire above is");
|
|
||||||
INFO("the primary format check.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5781,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
|||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE(doc.error.empty());
|
||||||
|
|
||||||
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
|
||||||
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
|
||||||
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
|
||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE(doc.error.empty());
|
||||||
|
|
||||||
@@ -5801,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
|||||||
gp_Pnt com = props.CentreOfMass();
|
gp_Pnt com = props.CentreOfMass();
|
||||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||||
|
|
||||||
|
Bnd_Box bb;
|
||||||
|
BRepBndLib::Add(doc.bodies[1].shape, bb);
|
||||||
|
double x0, y0, z0, x1, y1, z1;
|
||||||
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
||||||
|
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
|
||||||
|
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
||||||
@@ -5823,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
|||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE(doc.error.empty());
|
||||||
|
|
||||||
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
// The connector sits on the cylinder's base, so the flip decides which side of the
|
||||||
|
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
|
||||||
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
|
||||||
doc.features[cs_moving].coordsys_body = 1;
|
doc.features[cs_moving].coordsys_body = 1;
|
||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE(doc.error.empty());
|
||||||
@@ -5837,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
|||||||
gp_Pnt com = props.CentreOfMass();
|
gp_Pnt com = props.CentreOfMass();
|
||||||
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
||||||
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
||||||
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
|
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
|
||||||
// but the mate executed cleanly and the body moved to the target connector.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
|
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
|
||||||
@@ -6525,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
|
|||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE(doc.error.empty());
|
||||||
|
|
||||||
// Fastened, NO flip
|
// The same assembly mated once without and once with flip, each in its own copy.
|
||||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
|
auto mated_com_z = [&](bool flip) {
|
||||||
REQUIRE(doc.recompute());
|
CadDocument d = doc;
|
||||||
REQUIRE(doc.error.empty());
|
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
|
||||||
GProp_GProps nf_props;
|
REQUIRE(d.recompute());
|
||||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
|
REQUIRE(d.error.empty());
|
||||||
double nf_z = nf_props.CentreOfMass().Z();
|
REQUIRE(d.mate_conflicts.empty());
|
||||||
|
GProp_GProps props;
|
||||||
doc.undo();
|
BRepGProp::VolumeProperties(d.bodies[1].shape, props);
|
||||||
|
return props.CentreOfMass().Z();
|
||||||
// Fastened, WITH flip
|
};
|
||||||
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
|
const double nf_z = mated_com_z(false);
|
||||||
REQUIRE(doc.recompute());
|
const double f_z = mated_com_z(true);
|
||||||
REQUIRE(doc.error.empty());
|
|
||||||
GProp_GProps f_props;
|
|
||||||
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
|
|
||||||
double f_z = f_props.CentreOfMass().Z();
|
|
||||||
|
|
||||||
// Flip changes the centroid Z for an asymmetric body
|
// Flip changes the centroid Z for an asymmetric body
|
||||||
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
|
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
|
||||||
@@ -7324,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
|
|||||||
CadDocument doc;
|
CadDocument doc;
|
||||||
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
||||||
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
||||||
// A sheet passing straight through the solid: it has no volume, so no interference.
|
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
|
||||||
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
|
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
|
||||||
doc.add_surface_extrude(sk_s, 5.0, "SE");
|
doc.add_surface_extrude(sk_s, 5.0, "SE");
|
||||||
REQUIRE(doc.recompute());
|
REQUIRE(doc.recompute());
|
||||||
REQUIRE(doc.bodies.size() == 2);
|
REQUIRE(doc.bodies.size() == 2);
|
||||||
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
|
||||||
|
|
||||||
|
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
|
||||||
|
// skip keeps it out of the report.
|
||||||
|
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
|
||||||
|
common.Build();
|
||||||
|
REQUIRE(common.IsDone());
|
||||||
|
GProp_GProps props;
|
||||||
|
BRepGProp::VolumeProperties(common.Shape(), props);
|
||||||
|
REQUIRE(std::abs(props.Mass()) > 1e-6);
|
||||||
|
|
||||||
REQUIRE(doc.check_interference().empty());
|
REQUIRE(doc.check_interference().empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7540,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
|
|||||||
CHECK(doc.error.find("removed no material") != std::string::npos);
|
CHECK(doc.error.find("removed no material") != std::string::npos);
|
||||||
|
|
||||||
// And the body is left as it was, not half-applied.
|
// And the body is left as it was, not half-applied.
|
||||||
CadDocument again = box();
|
REQUIRE(doc.bodies.size() == 1);
|
||||||
REQUIRE(again.recompute());
|
CHECK_THAT(doc.body_mass_properties(0).volume,
|
||||||
CHECK_THAT(again.body_mass_properties(0).volume,
|
|
||||||
Catch::Matchers::WithinAbs(solid, 1e-6));
|
Catch::Matchers::WithinAbs(solid, 1e-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
-10
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
|
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
|
||||||
THEN("A BadOptionTypeException exception is thrown.") {
|
config.set("gcode_comments", true);
|
||||||
|
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
|
||||||
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
|
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
|
||||||
}
|
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
|
||||||
AND_THEN("Value is unchanged.") {
|
|
||||||
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN("A boolean option is set to an int value") {
|
WHEN("A boolean option is set to an int value") {
|
||||||
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN("A numeric option is set to a non-numeric value.") {
|
WHEN("A numeric option is set to a non-numeric value.") {
|
||||||
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
|
config.set("max_bridge_length", 7.5);
|
||||||
THEN("A BadOptionTypeException exception is thrown.") {
|
THEN("A BadOptionValueException exception is thrown and the value does not change.") {
|
||||||
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
|
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
|
||||||
}
|
REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
|
||||||
THEN("The value does not change.") {
|
|
||||||
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN("A string option is set through the string interface") {
|
WHEN("A string option is set through the string interface") {
|
||||||
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WHEN("An invalid option is requested during set.") {
|
WHEN("An invalid option is requested during set.") {
|
||||||
THEN("A BadOptionTypeException exception is thrown.") {
|
THEN("A UnknownOptionException exception is thrown.") {
|
||||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
|
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
|
||||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
|
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
|
||||||
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
|
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
|
||||||
@@ -1263,6 +1260,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
|
|||||||
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
|
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
|
||||||
|
SECTION("float") {
|
||||||
|
ConfigOptionFloat opt(7.5);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
|
||||||
|
}
|
||||||
|
SECTION("int") {
|
||||||
|
ConfigOptionInt opt(3);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc"));
|
||||||
|
CHECK(opt.value == 3);
|
||||||
|
}
|
||||||
|
SECTION("percent") {
|
||||||
|
ConfigOptionPercent opt(40.);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
|
||||||
|
}
|
||||||
|
SECTION("float or percent") {
|
||||||
|
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||||
|
REQUIRE_FALSE(opt.deserialize("abc%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
|
||||||
|
CHECK_FALSE(opt.percent);
|
||||||
|
}
|
||||||
|
SECTION("point") {
|
||||||
|
ConfigOptionPoint opt(Vec2d(1., 2.));
|
||||||
|
REQUIRE_FALSE(opt.deserialize("5,abc"));
|
||||||
|
CHECK(opt.value == Vec2d(1., 2.));
|
||||||
|
}
|
||||||
|
SECTION("point3") {
|
||||||
|
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
|
||||||
|
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
|
||||||
|
CHECK(opt.value == Vec3d(1., 2., 3.));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
|
||||||
|
ConfigOptionFloatOrPercent opt(0.4, false);
|
||||||
|
REQUIRE(opt.deserialize("50%"));
|
||||||
|
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
|
||||||
|
CHECK(opt.percent);
|
||||||
|
|
||||||
|
ConfigOptionPoint point;
|
||||||
|
REQUIRE(point.deserialize("3x4"));
|
||||||
|
CHECK(point.value == Vec2d(3., 4.));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
|
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
|
||||||
Slic3r::DynamicPrintConfig config;
|
Slic3r::DynamicPrintConfig config;
|
||||||
t_config_option_keys extra, keys;
|
t_config_option_keys extra, keys;
|
||||||
@@ -1678,3 +1724,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
|
|||||||
}
|
}
|
||||||
CHECK(enum_lists > 0);
|
CHECK(enum_lists > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
|
||||||
|
{
|
||||||
|
std::set<std::string> no_keys;
|
||||||
|
Slic3r::DynamicPrintConfig parent;
|
||||||
|
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
|
||||||
|
parent.set_deserialize_strict("filament_ironing_flow", "15%");
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||||
|
child.set_deserialize_strict("filament_ironing_flow", "nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
|
||||||
|
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
|
||||||
|
}
|
||||||
|
|
||||||
|
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
|
||||||
|
{
|
||||||
|
std::set<std::string> no_keys;
|
||||||
|
GIVEN("A two-variant filament parent") {
|
||||||
|
Slic3r::DynamicPrintConfig parent;
|
||||||
|
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||||
|
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
|
||||||
|
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
|
||||||
|
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
|
||||||
|
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("the override column is disabled while the plain option's nil column inherits") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WHEN("the child lists the same variants in the other order") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
|
||||||
|
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("columns are matched by variant name, the disabled one included") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
|
||||||
|
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WHEN("the child was saved with a single variant") {
|
||||||
|
Slic3r::DynamicPrintConfig child;
|
||||||
|
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
|
||||||
|
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
|
||||||
|
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
|
||||||
|
Slic3r::filament_options_with_variant, no_keys);
|
||||||
|
THEN("its one value applies to every variant of that extruder, as slicing does") {
|
||||||
|
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Every listed filament override is nullable", "[Config]")
|
||||||
|
{
|
||||||
|
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
|
||||||
|
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
|
||||||
|
for (const auto &[key, def] : Slic3r::print_config_def.options) {
|
||||||
|
INFO(key);
|
||||||
|
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
|
||||||
|
if (overrides.count(key) == 1)
|
||||||
|
CHECK(def.nullable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
|
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
|
||||||
{
|
{
|
||||||
// Regression for the "extra purge tower" on a belt printer with a mixed
|
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
|
||||||
// filament (MCTEST5). The belt purge prism is sized as
|
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
|
||||||
// n_islands = used_filaments.size() - 1
|
// components, so it must not count as a filament of its own.
|
||||||
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
|
|
||||||
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
|
|
||||||
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
|
|
||||||
// with its components before any G-code is emitted -- so counting it as a
|
|
||||||
// filament of its own provisions one island that can never be reached.
|
|
||||||
//
|
|
||||||
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
|
|
||||||
// filaments 2 and 4. The G-code uses only T0..T3 and reports
|
|
||||||
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
|
|
||||||
// consumes nothing, exactly as a virtual slot should.
|
|
||||||
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
|
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
|
||||||
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
|
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
|
||||||
|
|
||||||
// The set the sizer used to see: slots 0..4 (filaments 1..5).
|
|
||||||
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
|
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
|
||||||
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
|
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
|
||||||
|
|
||||||
// Slot 4 dissolves into 1 and 3, which are already present.
|
// Slot 4 dissolves into 1 and 3, which are already present.
|
||||||
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
|
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
|
||||||
|
|
||||||
// Four physical filaments => three transitions => three islands, not four.
|
|
||||||
REQUIRE(int(physical.size()) - 1 == 3);
|
|
||||||
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
|
|
||||||
|
|
||||||
SECTION("A mixed slot whose components are otherwise unused still counts them") {
|
SECTION("A mixed slot whose components are otherwise unused still counts them") {
|
||||||
// Only the mixed slot is assigned: it must still yield its two components,
|
// Only the mixed slot is assigned: it must still yield its two components rather than
|
||||||
// i.e. one island, rather than collapsing to zero.
|
// collapsing to nothing.
|
||||||
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
|
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
|
||||||
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
|
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
|
||||||
REQUIRE(int(only_mixed.size()) - 1 == 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("No mixed filaments anywhere leaves the set untouched") {
|
SECTION("No mixed filaments anywhere leaves the set untouched") {
|
||||||
|
|||||||
@@ -1334,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
|
|||||||
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
|
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
|
||||||
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
|
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
|
||||||
|
|
||||||
// Normalizing rewrites config in place without flagging the preset dirty.
|
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
|
||||||
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
|
bundle.prints.select_preset_by_name("My Process", true);
|
||||||
|
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
|
||||||
|
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||||
|
std::vector<std::string>{ "New Printer" });
|
||||||
|
|
||||||
// A system preset that already references the current name is left untouched (idempotent no-op).
|
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the
|
||||||
|
// edited copy, so the selection is not reported modified.
|
||||||
bundle.normalize_compatible_presets();
|
bundle.normalize_compatible_presets();
|
||||||
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
||||||
|
CHECK_FALSE(bundle.prints.current_is_dirty());
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
|
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
|
||||||
@@ -1369,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
|
|||||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
|
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
|
||||||
std::vector<std::string>{ "New Process" });
|
std::vector<std::string>{ "New Process" });
|
||||||
|
|
||||||
// The rewrite does not flag the system preset dirty, and is idempotent.
|
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
|
||||||
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
|
bundle.filaments.select_preset_by_name("System Filament", true);
|
||||||
|
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
|
||||||
|
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
|
||||||
|
std::vector<std::string>{ "New Printer" });
|
||||||
|
|
||||||
|
// Normalizing again is a no-op, so the selection is not reported modified.
|
||||||
bundle.normalize_compatible_presets();
|
bundle.normalize_compatible_presets();
|
||||||
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
||||||
std::vector<std::string>{ "New Printer" });
|
std::vector<std::string>{ "New Printer" });
|
||||||
|
CHECK_FALSE(bundle.filaments.current_is_dirty());
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
|
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
|
||||||
@@ -5566,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
|
|||||||
CHECK(bundle.num_mixed_filaments() == 0);
|
CHECK(bundle.num_mixed_filaments() == 0);
|
||||||
CHECK(bundle.num_physical_filaments() == target);
|
CHECK(bundle.num_physical_filaments() == target);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("applied as a floor, the mix is left alone") {
|
|
||||||
if (target > bundle.filament_presets.size())
|
|
||||||
bundle.set_num_filaments(target);
|
|
||||||
|
|
||||||
CHECK(bundle.filament_presets.size() == 6);
|
|
||||||
CHECK(bundle.num_mixed_filaments() == 1);
|
|
||||||
CHECK(bundle.is_mixed_filament(5));
|
|
||||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
|
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
|
||||||
@@ -8417,3 +8418,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
|
|||||||
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
|
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
|
||||||
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
|
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
|
||||||
|
{
|
||||||
|
ScopedPresetDataDir data_dir;
|
||||||
|
PresetBundle bundle;
|
||||||
|
|
||||||
|
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||||
|
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
|
||||||
|
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
|
||||||
|
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
|
||||||
|
|
||||||
|
// The child turns the override off on the first variant and changes a plain option on the second.
|
||||||
|
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
|
||||||
|
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
|
||||||
|
child.file = file.string();
|
||||||
|
child.version = *Semver::parse("1.0.0");
|
||||||
|
child.config = parent.config;
|
||||||
|
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
|
||||||
|
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
|
||||||
|
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
|
||||||
|
REQUIRE(child.save(&parent.config));
|
||||||
|
|
||||||
|
// On disk the override keeps every column, so its nil can only mean "off"; a plain
|
||||||
|
// option still stores nil for the columns that match the parent.
|
||||||
|
DynamicPrintConfig saved;
|
||||||
|
std::map<std::string, std::string> key_values;
|
||||||
|
std::string reason;
|
||||||
|
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
|
||||||
|
REQUIRE(reason.empty());
|
||||||
|
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
|
||||||
|
|
||||||
|
PresetsConfigSubstitutions substitutions;
|
||||||
|
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||||
|
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||||
|
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
|
||||||
|
REQUIRE(loaded != nullptr);
|
||||||
|
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
|
||||||
|
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
|
||||||
|
{
|
||||||
|
PresetBundle bundle;
|
||||||
|
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
|
||||||
|
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
|
||||||
|
// Every override is nil in both parent and child, so none is in the plain diff.
|
||||||
|
std::map<std::string, std::string> key_values;
|
||||||
|
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
|
||||||
|
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
|
||||||
|
CHECK(key_values.count("filament_retract_before_wipe") == 1);
|
||||||
|
CHECK(key_values["filament_retract_before_wipe"] == "nil");
|
||||||
|
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
|
||||||
|
CHECK(key_values.count("filament_ironing_flow") == 0);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
#include <catch2/matchers/catch_matchers.hpp>
|
#include <catch2/matchers/catch_matchers.hpp>
|
||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||||
#include "libslic3r/CAD/SketchImport.hpp"
|
#include "libslic3r/CAD/SketchImport.hpp"
|
||||||
#include "libslic3r/Utils.hpp" // resources_dir
|
|
||||||
#include "test_utils.hpp" // ScopedTemporaryFile
|
#include "test_utils.hpp" // ScopedTemporaryFile
|
||||||
|
|
||||||
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
|
|||||||
|
|
||||||
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
|
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
|
||||||
{
|
{
|
||||||
// Locate the bundled font; resources_dir() may be unset under ctest, so
|
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
|
||||||
// fall back to a cwd-relative path (tests run from the repo root).
|
const std::string font =
|
||||||
std::string font = resources_dir().empty()
|
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
|
||||||
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
REQUIRE(std::ifstream(font).good());
|
||||||
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
|
||||||
{
|
|
||||||
std::ifstream probe(font);
|
|
||||||
if (!probe.good()) {
|
|
||||||
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad input is rejected without throwing.
|
// Bad input is rejected without throwing.
|
||||||
REQUIRE(text_to_regions("", 10.0, font).empty());
|
REQUIRE(text_to_regions("", 10.0, font).empty());
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
|
|||||||
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
|
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
|
||||||
{
|
{
|
||||||
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
|
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
|
||||||
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
|
// Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
|
||||||
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
|
// wins even though the midpoint is nearer.
|
||||||
|
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
|
||||||
CHECK(s.kind == K::Endpoint);
|
CHECK(s.kind == K::Endpoint);
|
||||||
CHECK(s.role == SketchPointRole::P1);
|
CHECK(s.role == SketchPointRole::P1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
|
|||||||
REQUIRE(closed_wires(out) == 1);
|
REQUIRE(closed_wires(out) == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
|
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
|
||||||
{
|
{
|
||||||
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
|
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
|
||||||
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
|
// closed wire, not four disconnected segments. Its area is checked by the sign test below.
|
||||||
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
|
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
|
||||||
// a set of four disconnected segments is not, and that is what this measures.
|
|
||||||
const double w = 40, h = 20, d = 5;
|
|
||||||
const auto out = SketchEngine::offset_entities(rect(w, h), d);
|
|
||||||
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
|
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
|
||||||
REQUIRE(ws.size() == 1);
|
REQUIRE(ws.size() == 1);
|
||||||
REQUIRE(ws[0].Closed());
|
REQUIRE(ws[0].Closed());
|
||||||
|
|||||||
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
|
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
|
||||||
{
|
{
|
||||||
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
|
|
||||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||||
|
|
||||||
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
||||||
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
|
|||||||
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
||||||
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
||||||
|
|
||||||
auto has_multiple_nozzle = [](const std::vector<int> &values) {
|
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
|
||||||
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
|
// (any extruder_max_nozzle_count > 1) stays closed.
|
||||||
};
|
REQUIRE_FALSE(opt->values.empty());
|
||||||
|
for (int count : opt->values)
|
||||||
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
|
CHECK(count == 1);
|
||||||
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
|
|
||||||
|
|
||||||
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
|
|
||||||
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
|
|
||||||
using VD = Geometry::VoronoiDiagram;
|
using VD = Geometry::VoronoiDiagram;
|
||||||
|
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
|
||||||
using BoostVD = boost::polygon::voronoi_diagram<double>;
|
using BoostVD = boost::polygon::voronoi_diagram<double>;
|
||||||
|
|
||||||
// https://svn.boost.org/trac10/ticket/12067
|
// https://svn.boost.org/trac10/ticket/12067
|
||||||
@@ -198,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
|
|||||||
REQUIRE(intersecting_edges({ poly }).empty());
|
REQUIRE(intersecting_edges({ poly }).empty());
|
||||||
|
|
||||||
Lines lines = to_lines(poly);
|
Lines lines = to_lines(poly);
|
||||||
BoostVD vd;
|
VD vd;
|
||||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
vd.construct_voronoi(lines.begin(), lines.end());
|
||||||
|
// Orca's detector finds nothing wrong with this input, so no repair is needed.
|
||||||
|
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
|
||||||
|
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
|
||||||
|
|
||||||
#ifdef VORONOI_DEBUG_OUT
|
#ifdef VORONOI_DEBUG_OUT
|
||||||
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
|
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
|
||||||
@@ -305,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
BoostVD vd;
|
VD vd;
|
||||||
Lines lines = to_lines(poly);
|
Lines lines = to_lines(poly);
|
||||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
vd.construct_voronoi(lines.begin(), lines.end());
|
||||||
|
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
|
||||||
|
// by rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
|
||||||
#ifdef VORONOI_DEBUG_OUT
|
#ifdef VORONOI_DEBUG_OUT
|
||||||
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
|
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
|
||||||
@@ -347,9 +359,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
|
|||||||
|
|
||||||
// https://svn.boost.org/trac10/ticket/12139
|
// https://svn.boost.org/trac10/ticket/12139
|
||||||
// Funny sample from a dental industry?
|
// Funny sample from a dental industry?
|
||||||
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
|
// The input data contain self intersections, so no valid Voronoi diagram can be built from it.
|
||||||
// This test is suppressed.
|
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
|
||||||
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
|
|
||||||
{
|
{
|
||||||
Lines lines = {
|
Lines lines = {
|
||||||
{ { 260500,1564400 }, { 261040,1562960 } },
|
{ { 260500,1564400 }, { 261040,1562960 } },
|
||||||
@@ -1327,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
BoostVD vd;
|
VD vd;
|
||||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
vd.construct_voronoi(lines.begin(), lines.end());
|
||||||
|
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
|
||||||
for (const auto& edge : vd.edges())
|
// input does not repair it. It must be reported as invalid, which is what MedialAxis::build
|
||||||
if (edge.is_finite()) {
|
// checks before falling back to a morphological closing.
|
||||||
auto v0 = edge.vertex0();
|
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
|
||||||
auto v1 = edge.vertex1();
|
CHECK_FALSE(vd.is_valid());
|
||||||
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
|
|
||||||
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
|
|
||||||
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
|
|
||||||
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef VORONOI_DEBUG_OUT
|
#ifdef VORONOI_DEBUG_OUT
|
||||||
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
|
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
|
||||||
@@ -1935,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
|
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
|
||||||
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
|
||||||
{
|
{
|
||||||
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
|
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
|
||||||
|
|
||||||
@@ -1957,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
|
|||||||
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
|
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
|
||||||
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
|
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
|
||||||
// using the intersection between the infinity-edge with the input polygon.
|
// using the intersection between the infinity-edge with the input polygon.
|
||||||
// Rotation of the polygon solves this problem.
|
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||||
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
||||||
{
|
{
|
||||||
Polygon poly = {
|
Polygon poly = {
|
||||||
@@ -1981,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
|||||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
|
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
|
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||||
|
// rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
|
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
|
||||||
@@ -1989,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
|
|||||||
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
|
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
|
||||||
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
|
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
|
||||||
// input polygon.
|
// input polygon.
|
||||||
// Rotation of the polygons solves this problem.
|
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||||
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
||||||
{
|
{
|
||||||
Polygons poly = {
|
Polygons poly = {
|
||||||
@@ -2021,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
|
|||||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
|
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||||
|
// rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
|
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
|
||||||
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
|
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
|
||||||
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
|
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
|
||||||
// Rotating the polygon also help solve this problem.
|
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||||
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
||||||
{
|
{
|
||||||
Polygons poly = {
|
Polygons poly = {
|
||||||
@@ -2055,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
|
|||||||
// polygons_rotate(poly, PI/180);
|
// polygons_rotate(poly, PI/180);
|
||||||
// polygons_rotate(poly, PI/6);
|
// polygons_rotate(poly, PI/6);
|
||||||
|
|
||||||
BoostVD vd;
|
VD vd;
|
||||||
Lines lines = to_lines(poly);
|
Lines lines = to_lines(poly);
|
||||||
construct_voronoi(lines.begin(), lines.end(), &vd);
|
vd.construct_voronoi(lines.begin(), lines.end());
|
||||||
#ifdef VORONOI_DEBUG_OUT
|
#ifdef VORONOI_DEBUG_OUT
|
||||||
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
|
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
|
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||||
|
// rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// In this case, the Voronoi vertex (146873, -146873) is included twice.
|
// In this case, the Voronoi vertex (146873, -146873) is included twice.
|
||||||
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
|
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
|
||||||
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
|
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
|
||||||
// Rotating of the input polygon will help solve this problem.
|
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||||
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
||||||
{
|
{
|
||||||
Polygon poly = {
|
Polygon poly = {
|
||||||
@@ -2095,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
|||||||
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
|
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
auto has_duplicate_vertices = [](const VD &vd) -> bool {
|
||||||
std::vector<Vec2d> vertices;
|
std::vector<Vec2d> vertices;
|
||||||
for (const auto &vertex : vd.vertices())
|
for (const auto &vertex : vd.vertices())
|
||||||
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
|
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
|
||||||
@@ -2104,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
|
|||||||
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
|
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
|
||||||
};
|
};
|
||||||
|
|
||||||
// REQUIRE(!has_duplicate_vertices(vd));
|
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||||
|
// rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(has_duplicate_vertices(raw_vd));
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK_FALSE(has_duplicate_vertices(vd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
|
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
|
||||||
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
|
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
|
||||||
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
|
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
|
||||||
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
|
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
|
||||||
// Rotating of the input polygon will help solve this problem.
|
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
|
||||||
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
||||||
{
|
{
|
||||||
Polygon poly = {
|
Polygon poly = {
|
||||||
@@ -2135,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
|||||||
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
|
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
|
||||||
BoundingBox bbox = get_extents(poly);
|
BoundingBox bbox = get_extents(poly);
|
||||||
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
|
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
|
||||||
|
|
||||||
@@ -2166,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// REQUIRE(!has_intersecting_edges(poly, vd));
|
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
|
||||||
|
// rotating the input.
|
||||||
|
VD raw_vd;
|
||||||
|
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
|
||||||
|
CHECK(has_intersecting_edges(poly, raw_vd));
|
||||||
|
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
|
||||||
|
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
|
||||||
|
CHECK_FALSE(has_intersecting_edges(poly, vd));
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,48 @@
|
|||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
|
#include <boost/system/error_code.hpp>
|
||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <libslic3r/Utils.hpp>
|
#include <libslic3r/Utils.hpp>
|
||||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||||
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||||
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||||
|
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
#include <slic3r/Utils/IPrinterAgent.hpp>
|
||||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
||||||
|
|
||||||
|
#include <catch2/catch_message.hpp>
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers.hpp>
|
||||||
|
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||||
#include "libslic3r_version.h"
|
#include "libslic3r_version.h"
|
||||||
#include "plugin_test_utils.hpp"
|
#include "plugin_test_utils.hpp"
|
||||||
|
#include "test_utils.hpp"
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <pybind11/cast.h>
|
||||||
|
#include <pybind11/embed.h>
|
||||||
|
#include <pybind11/eval.h>
|
||||||
|
#include <pybind11/gil.h>
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/pytypes.h>
|
||||||
|
#include <pybind11/stl.h>
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <functional>
|
||||||
|
#include <ios>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
namespace fs = boost::filesystem;
|
namespace fs = boost::filesystem;
|
||||||
|
namespace py = pybind11;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -349,3 +375,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
|
|||||||
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The audit hook, run in the embedded interpreter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
|
||||||
|
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
|
||||||
|
// cases instead of using SECTIONs, which would restart the interpreter for every section.
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
|
||||||
|
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
#
|
||||||
|
# [tool.orcaslicer.plugin]
|
||||||
|
# name = "Audit Plugin"
|
||||||
|
# description = "Plugin audit hook fixture"
|
||||||
|
# author = "OrcaSlicer"
|
||||||
|
# version = "1.0"
|
||||||
|
# type = "script"
|
||||||
|
# ///
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
// A third-party library, installed outside the stdlib and outside the plugin.
|
||||||
|
const char* const LIBRARY_SOURCE = R"PY(
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def read_all(paths):
|
||||||
|
for path in paths:
|
||||||
|
with open(path) as f:
|
||||||
|
f.read()
|
||||||
|
|
||||||
|
def connect_from_host(port):
|
||||||
|
socket.gethostname()
|
||||||
|
socket.create_connection(("127.0.0.1", port)).close()
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
def fetch_each(urls):
|
||||||
|
for url in urls:
|
||||||
|
yield fetch(url)
|
||||||
|
|
||||||
|
class ReadingHandler(urllib.request.BaseHandler):
|
||||||
|
"""Reads a file while each response is processed."""
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.outcome = None
|
||||||
|
|
||||||
|
def http_response(self, request, response):
|
||||||
|
try:
|
||||||
|
open(self.path).close()
|
||||||
|
self.outcome = "ok"
|
||||||
|
except PermissionError:
|
||||||
|
self.outcome = "denied"
|
||||||
|
return response
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
|
||||||
|
// HTTP server, the library, and every module the tests use imported up front, so that no import
|
||||||
|
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
|
||||||
|
// attempt() and fetch() stand in for plugin code.
|
||||||
|
const char* const AUDIT_SETUP = R"PY(
|
||||||
|
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
|
||||||
|
import urllib.request, zipfile
|
||||||
|
if os.name != "nt":
|
||||||
|
import pty
|
||||||
|
|
||||||
|
APPROVED = os.path.join(OUTSIDE, "approved.txt")
|
||||||
|
PRIVATE = os.path.join(OUTSIDE, "private.txt")
|
||||||
|
MISSING = os.path.join(OUTSIDE, "missing")
|
||||||
|
TREE = os.path.join(OUTSIDE, "tree")
|
||||||
|
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
|
||||||
|
for path in (APPROVED, PRIVATE):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
for sub in ("one", "two"):
|
||||||
|
os.makedirs(os.path.join(TREE, sub))
|
||||||
|
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
|
||||||
|
LIBRARY = os.path.join(OUTSIDE, "library")
|
||||||
|
os.makedirs(LIBRARY)
|
||||||
|
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
|
||||||
|
f.write(LIBRARY_SOURCE)
|
||||||
|
sys.path.insert(0, LIBRARY)
|
||||||
|
import netlib
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/redirect":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", URL_B)
|
||||||
|
body = b""
|
||||||
|
else:
|
||||||
|
self.send_response(200)
|
||||||
|
body = b"ok"
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
PORT = server.server_address[1]
|
||||||
|
BASE = "http://127.0.0.1:%d" % PORT
|
||||||
|
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
|
||||||
|
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
fetch(URL_A) # urllib imports some modules on its first request
|
||||||
|
|
||||||
|
def attempt(action):
|
||||||
|
try:
|
||||||
|
action()
|
||||||
|
return "ok"
|
||||||
|
except PermissionError:
|
||||||
|
return "denied"
|
||||||
|
|
||||||
|
def spawn_checked(argv):
|
||||||
|
parent = os.getpid()
|
||||||
|
try:
|
||||||
|
return pty.spawn(argv)
|
||||||
|
finally:
|
||||||
|
if os.getpid() != parent:
|
||||||
|
os._exit(1) # a child whose exec was refused must not go on running the tests
|
||||||
|
|
||||||
|
finalizer_outcomes = []
|
||||||
|
|
||||||
|
class TrackedRequest(urllib.request.Request):
|
||||||
|
def __del__(self):
|
||||||
|
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
struct Prompt
|
||||||
|
{
|
||||||
|
std::string plugin;
|
||||||
|
std::string event;
|
||||||
|
std::vector<std::string> targets;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One line per prompt, "event: target ...", so a failed check shows every prompt.
|
||||||
|
std::string describe(const std::vector<Prompt>& prompts)
|
||||||
|
{
|
||||||
|
std::string text;
|
||||||
|
for (const Prompt& prompt : prompts) {
|
||||||
|
text += prompt.event + ":";
|
||||||
|
for (const std::string& target : prompt.targets)
|
||||||
|
text += " " + target;
|
||||||
|
text += "\n";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin system with the audit hook installed, and the permission dialog replaced by a
|
||||||
|
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
|
||||||
|
struct AuditHarness
|
||||||
|
{
|
||||||
|
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||||
|
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
|
||||||
|
ScopedDataDir data{"plugin-audit-hook"};
|
||||||
|
bool initialized = PluginManager::instance().initialize();
|
||||||
|
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
|
||||||
|
std::vector<Prompt> prompts;
|
||||||
|
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
|
||||||
|
|
||||||
|
AuditHarness()
|
||||||
|
{
|
||||||
|
PluginAuditManager::set_permission_prompt_for_testing(
|
||||||
|
[this](const std::string& event, const std::vector<std::string>& targets) {
|
||||||
|
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
|
||||||
|
return approve(prompts.back());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
~AuditHarness()
|
||||||
|
{
|
||||||
|
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
|
||||||
|
PluginManager::instance().shutdown();
|
||||||
|
PythonInterpreter::instance().shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call before taking the GIL: discovery is not Python work.
|
||||||
|
void add_plugin(const std::string& key)
|
||||||
|
{
|
||||||
|
const fs::path root = data.plugins_dir() / key;
|
||||||
|
fs::create_directories(root);
|
||||||
|
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
|
||||||
|
save_permissions(key, {});
|
||||||
|
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grants as if saved in an earlier session.
|
||||||
|
void save_permissions(const std::string& key, const PluginPermissions& permissions)
|
||||||
|
{
|
||||||
|
PluginInstallState state;
|
||||||
|
state.installed_from = "local";
|
||||||
|
state.installed_version = "1.0";
|
||||||
|
state.plugin_name = key;
|
||||||
|
state.permissions = permissions;
|
||||||
|
REQUIRE(write_install_state(data.plugins_dir() / key, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginPermissions saved_permissions(const std::string& key) const
|
||||||
|
{
|
||||||
|
PluginInstallState state;
|
||||||
|
read_install_state(data.plugins_dir() / key, state);
|
||||||
|
return state.permissions;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
|
||||||
|
// server before the interpreter shuts down.
|
||||||
|
struct PythonSide
|
||||||
|
{
|
||||||
|
py::dict globals;
|
||||||
|
|
||||||
|
explicit PythonSide(const AuditHarness& harness)
|
||||||
|
{
|
||||||
|
globals["OUTSIDE"] = harness.outside.path().string();
|
||||||
|
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
|
||||||
|
exec(AUDIT_SETUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Python error fails the test here, as text: an error_already_set left to propagate would
|
||||||
|
// outlive the interpreter the harness shuts down.
|
||||||
|
void exec(const char* code)
|
||||||
|
{
|
||||||
|
std::string error;
|
||||||
|
try {
|
||||||
|
py::exec(code, globals);
|
||||||
|
} catch (py::error_already_set& raised) {
|
||||||
|
error = raised.what();
|
||||||
|
}
|
||||||
|
if (!error.empty())
|
||||||
|
FAIL(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
~PythonSide()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
globals["server"].attr("shutdown")();
|
||||||
|
globals["server"].attr("server_close")();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
|
||||||
|
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
|
||||||
|
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
|
||||||
|
|
||||||
|
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
|
||||||
|
// audit scope of its own.
|
||||||
|
void run_as(const std::string& plugin_key, const char* code)
|
||||||
|
{
|
||||||
|
ScopedPluginAuditContext audit(plugin_key);
|
||||||
|
exec(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginPermissions http_grants(std::vector<std::string> urls)
|
||||||
|
{
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.network_http = std::move(urls);
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* const PLUGIN = "AuditPlugin";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
|
||||||
|
|
||||||
|
CHECK(python.str("outcome") == "ok");
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
|
||||||
|
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
|
||||||
|
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// The redirect target is asked for inside the approved redirecting request, and a library's
|
||||||
|
// wrapper or generator asks for the second URL as plain code does.
|
||||||
|
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
|
||||||
|
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
|
||||||
|
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
python.exec(R"PY(
|
||||||
|
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
|
||||||
|
handler = netlib.ReadingHandler(PRIVATE)
|
||||||
|
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
|
||||||
|
walk_outcomes = []
|
||||||
|
copy_outcomes = []
|
||||||
|
|
||||||
|
def on_walk_error(error):
|
||||||
|
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
|
||||||
|
def copy_file(source, destination):
|
||||||
|
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
|
||||||
|
)PY");
|
||||||
|
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||||
|
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
|
||||||
|
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
|
||||||
|
// A library handler that urllib calls while the approved request runs, the plugin's own
|
||||||
|
// os.walk error callback and copytree copy function (called once per file), and a library
|
||||||
|
// loop over a saved file and an unsaved one.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
|
||||||
|
for _ in os.walk(MISSING, onerror=on_walk_error):
|
||||||
|
pass
|
||||||
|
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
|
||||||
|
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
CHECK(python.str("request_outcome") == "ok");
|
||||||
|
CHECK(python.eval("handler.outcome") == "denied");
|
||||||
|
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
|
||||||
|
CHECK(python.str("loop_outcome") == "denied");
|
||||||
|
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
|
||||||
|
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
|
||||||
|
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
|
||||||
|
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
|
||||||
|
// next audited event lets go.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
walked = []
|
||||||
|
for root, dirs, files in os.walk(TREE):
|
||||||
|
walked.append(root)
|
||||||
|
open(APPROVED).close()
|
||||||
|
globbed = list(pathlib.Path(TREE).glob("**/*"))
|
||||||
|
|
||||||
|
abandoned = os.walk(TREE)
|
||||||
|
next(abandoned)
|
||||||
|
del abandoned
|
||||||
|
open(APPROVED).close()
|
||||||
|
for _ in pathlib.Path(TREE).glob("*"):
|
||||||
|
break
|
||||||
|
open(APPROVED).close()
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
CHECK(py::len(python.globals["walked"]) == 3);
|
||||||
|
CHECK(py::len(python.globals["globbed"]) == 4);
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
|
||||||
|
struct Case
|
||||||
|
{
|
||||||
|
const char* prompts;
|
||||||
|
const char* code;
|
||||||
|
};
|
||||||
|
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
|
||||||
|
// FsDelete grants are never saved, so those operations are asked every time. Only the
|
||||||
|
// expected prompts are approved, so a nested event left uncovered fails the operation.
|
||||||
|
std::vector<Case> cases = {
|
||||||
|
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
|
||||||
|
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
|
||||||
|
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
|
||||||
|
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
|
||||||
|
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
|
||||||
|
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
|
||||||
|
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
|
||||||
|
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
|
||||||
|
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
|
||||||
|
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
|
||||||
|
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
|
||||||
|
// send() runs after connect() has returned, so its own grant is asked once.
|
||||||
|
{"http.client.connect\nhttp.client.send",
|
||||||
|
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
|
||||||
|
"connection.request('GET', '/a')\n"
|
||||||
|
"connection.getresponse().read()\n"
|
||||||
|
"connection.close()"},
|
||||||
|
};
|
||||||
|
#ifndef _WIN32
|
||||||
|
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
|
||||||
|
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
|
||||||
|
// denial would end it with status 1.
|
||||||
|
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
|
||||||
|
#endif
|
||||||
|
|
||||||
|
for (const Case& operation : cases) {
|
||||||
|
INFO(operation.code);
|
||||||
|
const std::string expected = std::string(operation.prompts) + "\n";
|
||||||
|
harness.approve = [&expected](const Prompt& prompt) {
|
||||||
|
return expected.find(prompt.event + "\n") != std::string::npos;
|
||||||
|
};
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, operation.code);
|
||||||
|
|
||||||
|
std::string asked;
|
||||||
|
for (const Prompt& prompt : harness.prompts)
|
||||||
|
asked += (asked.empty() ? "" : "\n") + prompt.event;
|
||||||
|
CHECK(asked == operation.prompts);
|
||||||
|
}
|
||||||
|
#ifndef _WIN32
|
||||||
|
CHECK(python.eval("spawn_status") == "0");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
socket.socket().close()
|
||||||
|
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
|
||||||
|
udp.close()
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// The socket is asked for once, without a target to show; the datagram's address is a target.
|
||||||
|
CHECK(python.str("sendto_outcome") == "denied");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
|
||||||
|
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, "socket.socket().close()");
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
|
||||||
|
// A grant for an event with no target covers nothing else: a library's approved
|
||||||
|
// gethostname() does not carry the connection it makes next.
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
|
||||||
|
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
|
||||||
|
CHECK(python.str("connect_outcome") == "denied");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
|
||||||
|
harness.prompts.clear();
|
||||||
|
harness.approve = [](const Prompt&) { return true; };
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
// sendmsg() without an address names no target either.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
left, right = socket.socketpair()
|
||||||
|
left.sendmsg([b"x"])
|
||||||
|
left.close()
|
||||||
|
right.close()
|
||||||
|
)PY");
|
||||||
|
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events ==
|
||||||
|
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
python.exec(R"PY(
|
||||||
|
READ_FD = os.open(APPROVED, os.O_RDONLY)
|
||||||
|
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
// A file descriptor has no path to show or save: a granted read of one is not a write.
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
|
||||||
|
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
|
||||||
|
CHECK(describe(harness.prompts) == "open:\nopen:\n");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
|
||||||
|
|
||||||
|
// A saved os.fork event grant is not an os.system command named "os.fork".
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
harness.prompts.clear();
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
|
||||||
|
system_outcome = attempt(lambda: os.system("os.fork"))
|
||||||
|
os.close(READ_FD)
|
||||||
|
os.close(WRITE_FD)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.str("reread_outcome") == "ok");
|
||||||
|
CHECK(python.str("system_outcome") == "denied");
|
||||||
|
REQUIRE(harness.prompts.size() == 1);
|
||||||
|
CHECK(harness.prompts.front().event == "os.system");
|
||||||
|
CHECK(harness.saved_permissions(PLUGIN).process.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin(PLUGIN);
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions = http_grants({python.str("URL_A")});
|
||||||
|
permissions.fs_read = {python.str("APPROVED")};
|
||||||
|
harness.save_permissions(PLUGIN, permissions);
|
||||||
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||||
|
|
||||||
|
// Once the request returns, only its held frame keeps the request object alive, and the
|
||||||
|
// request's finalizer reads a file the plugin was never granted.
|
||||||
|
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
|
||||||
|
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
CHECK_FALSE(mgr.audit_denial_pending());
|
||||||
|
|
||||||
|
python.exec("finalizer_outcomes.clear()");
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fetch(TrackedRequest(URL_A))
|
||||||
|
open(APPROVED).close() # the next event lets the returned request's frame go
|
||||||
|
outcomes_before_scope_end = list(finalizer_outcomes)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(describe(harness.prompts).empty());
|
||||||
|
|
||||||
|
// The finalizer's denial during another event's decision leaves that event's own denial intact.
|
||||||
|
python.exec("finalizer_outcomes.clear()");
|
||||||
|
python.run_as(PLUGIN, R"PY(
|
||||||
|
fetch(TrackedRequest(URL_A))
|
||||||
|
try:
|
||||||
|
os.listdir(OUTSIDE)
|
||||||
|
outer_error = ""
|
||||||
|
except PermissionError as error:
|
||||||
|
outer_error = str(error)
|
||||||
|
)PY");
|
||||||
|
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
|
||||||
|
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
|
||||||
|
AuditViolation violation;
|
||||||
|
REQUIRE(mgr.last_violation(violation));
|
||||||
|
CHECK(violation.event_name == "os.listdir");
|
||||||
|
CHECK(mgr.audit_denial_pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.add_plugin("PluginA");
|
||||||
|
harness.add_plugin("PluginB");
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
PluginPermissions permissions;
|
||||||
|
permissions.fs_read = {python.str("TREE")};
|
||||||
|
harness.save_permissions("PluginA", permissions);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Two walks plugin A started and suspended after listing the tree itself.
|
||||||
|
ScopedPluginAuditContext plugin_a("PluginA");
|
||||||
|
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
|
||||||
|
{
|
||||||
|
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
|
||||||
|
ScopedPluginAuditContext plugin_b("PluginB");
|
||||||
|
python.exec("listed_by_b = len(list(first))");
|
||||||
|
}
|
||||||
|
// Back in plugin A's scope, its walks are covered again.
|
||||||
|
python.exec("listed_by_a = len(list(second))");
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
|
||||||
|
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
|
||||||
|
REQUIRE(harness.prompts.size() == 2);
|
||||||
|
for (const Prompt& prompt : harness.prompts) {
|
||||||
|
CHECK(prompt.plugin == "PluginB");
|
||||||
|
CHECK(prompt.event == "os.scandir");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
PythonSide python(harness);
|
||||||
|
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||||
|
python.globals["orca"] = py::module_::import("orca");
|
||||||
|
|
||||||
|
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
|
||||||
|
// in its locals; the frame is let go when the capability call ends, which runs the marker's
|
||||||
|
// finalizer.
|
||||||
|
python.exec(R"PY(
|
||||||
|
class Marker:
|
||||||
|
def __init__(self, action=None):
|
||||||
|
self.action = action
|
||||||
|
def __del__(self):
|
||||||
|
finalized.append(self.action() if self.action else "ran")
|
||||||
|
|
||||||
|
finalized = []
|
||||||
|
|
||||||
|
def walk_and_drop(marker):
|
||||||
|
walker = os.walk(TREE, onerror=marker)
|
||||||
|
next(walker)
|
||||||
|
del walker
|
||||||
|
|
||||||
|
class ConfigCapability(orca.PythonPluginBase):
|
||||||
|
def get_name(self):
|
||||||
|
return "config_cap"
|
||||||
|
def get_default_config(self):
|
||||||
|
walk_and_drop(Marker())
|
||||||
|
return {"finalized_during_call": len(finalized)}
|
||||||
|
def has_config_ui(self):
|
||||||
|
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
|
||||||
|
next(walker)
|
||||||
|
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
|
||||||
|
del walker # the walk's frame is now held by the audit scope alone
|
||||||
|
raise RuntimeError("the capability fails after a denial")
|
||||||
|
|
||||||
|
class TicketAgent(orca.printer_agent.PrinterAgentBase):
|
||||||
|
def get_name(self):
|
||||||
|
return "ticket_agent"
|
||||||
|
def request_bind_ticket(self):
|
||||||
|
walk_and_drop(Marker())
|
||||||
|
return (0, "ticket")
|
||||||
|
)PY");
|
||||||
|
|
||||||
|
py::object config_instance = python.globals["ConfigCapability"]();
|
||||||
|
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||||
|
config_cap->set_audit_plugin_key("GilPlugin");
|
||||||
|
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
|
||||||
|
|
||||||
|
py::object agent_instance = python.globals["TicketAgent"]();
|
||||||
|
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||||
|
agent_cap->set_audit_plugin_key("GilPlugin");
|
||||||
|
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
|
||||||
|
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
|
||||||
|
REQUIRE(agent);
|
||||||
|
|
||||||
|
// A host call arrives without the GIL; both calls take it before their audit scope.
|
||||||
|
nlohmann::json config;
|
||||||
|
int ticket_result = -1;
|
||||||
|
std::string ticket;
|
||||||
|
{
|
||||||
|
py::gil_scoped_release released;
|
||||||
|
config = config_cap->get_default_config();
|
||||||
|
ticket_result = agent->request_bind_ticket(&ticket);
|
||||||
|
}
|
||||||
|
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
|
||||||
|
CHECK(ticket_result == 0);
|
||||||
|
CHECK(ticket == "ticket");
|
||||||
|
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
|
||||||
|
|
||||||
|
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
|
||||||
|
// was cleared: the finalizer's read is audited and denied without a prompt.
|
||||||
|
python.exec("finalized.clear()");
|
||||||
|
harness.prompts.clear();
|
||||||
|
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
|
||||||
|
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
|
||||||
|
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
|
||||||
|
{
|
||||||
|
AuditHarness harness;
|
||||||
|
if (!harness.initialized)
|
||||||
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
|
py::gil_scoped_acquire gil;
|
||||||
|
|
||||||
|
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
|
||||||
|
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
|
||||||
|
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
|
||||||
|
const fs::path link = harness.outside.path() / "linked-stdlib";
|
||||||
|
boost::system::error_code ec;
|
||||||
|
fs::create_directory_symlink(stdlib, link, ec);
|
||||||
|
if (ec)
|
||||||
|
SKIP("Cannot create a directory symlink here: " + ec.message());
|
||||||
|
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
|
||||||
|
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/catch_message.hpp>
|
||||||
#include "plugin_test_utils.hpp"
|
#include "plugin_test_utils.hpp"
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <pybind11/eval.h>
|
#include <pybind11/eval.h>
|
||||||
@@ -17,6 +18,12 @@
|
|||||||
#include <pybind11/embed.h>
|
#include <pybind11/embed.h>
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
|
|
||||||
|
#include <boost/filesystem/operations.hpp>
|
||||||
|
#include <boost/filesystem/path.hpp>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <fstream>
|
||||||
|
#include <ios>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <pybind11/cast.h>
|
#include <pybind11/cast.h>
|
||||||
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
|
|||||||
return {type, name, plugin_key};
|
return {type, name, plugin_key};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
|
||||||
|
// plugin rather than building a detached capability.
|
||||||
|
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
|
||||||
|
|
||||||
|
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
#
|
||||||
|
# [tool.orcaslicer.plugin]
|
||||||
|
# name = "Restore Plugin"
|
||||||
|
# description = "Restore defaults fixture"
|
||||||
|
# author = "OrcaSlicer"
|
||||||
|
# version = "1.0"
|
||||||
|
# type = "script"
|
||||||
|
# ///
|
||||||
|
import orca
|
||||||
|
|
||||||
|
class Target(orca.script.ScriptPluginCapabilityBase):
|
||||||
|
def get_name(self): return "Target"
|
||||||
|
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||||
|
def get_default_config(self): return {"speed": 1}
|
||||||
|
|
||||||
|
class Bystander(orca.script.ScriptPluginCapabilityBase):
|
||||||
|
def get_name(self): return "Bystander"
|
||||||
|
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||||
|
def get_default_config(self): return {"speed": 2}
|
||||||
|
|
||||||
|
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
|
||||||
|
# answers the load and raises from then on.
|
||||||
|
class Raising(orca.script.ScriptPluginCapabilityBase):
|
||||||
|
calls = 0
|
||||||
|
def get_name(self): return "Raising"
|
||||||
|
def execute(self, ctx): return orca.ExecutionResult.success()
|
||||||
|
def get_default_config(self):
|
||||||
|
Raising.calls += 1
|
||||||
|
if Raising.calls > 1:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@orca.plugin
|
||||||
|
class RestorePackage(orca.base):
|
||||||
|
def register_capabilities(self):
|
||||||
|
orca.register_capability(Target)
|
||||||
|
orca.register_capability(Bystander)
|
||||||
|
orca.register_capability(Raising)
|
||||||
|
)PY";
|
||||||
|
|
||||||
|
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
|
||||||
|
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
|
||||||
|
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
|
||||||
|
{
|
||||||
|
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
|
||||||
|
boost::filesystem::create_directories(plugin_dir);
|
||||||
|
{
|
||||||
|
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
|
||||||
|
out << RESTORE_PLUGIN_SOURCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginManager& manager = PluginManager::instance();
|
||||||
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||||
|
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
|
||||||
|
std::string error;
|
||||||
|
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
|
||||||
|
error = "the plugin did not load";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||||
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
|
|||||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||||
if (!plugin_system.initialized)
|
if (!plugin_system.initialized)
|
||||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
|
||||||
|
|
||||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||||
host_config().load();
|
host_config().load();
|
||||||
|
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||||
|
INFO("load error: " << load_error);
|
||||||
|
REQUIRE(load_error.empty());
|
||||||
|
|
||||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
|
||||||
" def get_default_config(self): return {'speed': 1}\n";
|
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
|
||||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
|
||||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
|
||||||
|
|
||||||
const json edited = json{{"speed", 99}};
|
const json edited = json{{"speed", 99}};
|
||||||
REQUIRE(py_save_config(target, edited));
|
REQUIRE(host_config().store_capability_config(target, edited));
|
||||||
REQUIRE(py_save_config(bystander, edited));
|
REQUIRE(host_config().store_capability_config(bystander, edited));
|
||||||
|
|
||||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
const json response = PluginConfig::restore_config_response(target);
|
||||||
auto iface = as_interface(target);
|
|
||||||
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
|
|
||||||
|
|
||||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
|
CHECK(response["ok"] == true);
|
||||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
|
CHECK(response["config"] == json{{"speed", 1}});
|
||||||
|
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
|
||||||
|
CHECK(host_config().get_config(bystander)->config == edited);
|
||||||
|
|
||||||
|
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||||
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
|
|||||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||||
if (!plugin_system.initialized)
|
if (!plugin_system.initialized)
|
||||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
|
||||||
|
|
||||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||||
host_config().load();
|
host_config().load();
|
||||||
|
const std::string load_error = load_restore_plugin(data_dir_guard);
|
||||||
|
INFO("load error: " << load_error);
|
||||||
|
REQUIRE(load_error.empty());
|
||||||
|
|
||||||
py::object cap = make_capability("RaisingDefaultsCap",
|
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
|
||||||
" def get_name(self): return 'cap_a'\n"
|
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
|
||||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
|
||||||
"plugin_a", "cap_a");
|
|
||||||
|
|
||||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
const json response = PluginConfig::restore_config_response(raising);
|
||||||
|
|
||||||
auto iface = as_interface(cap);
|
CHECK(response["ok"] == false);
|
||||||
REQUIRE(iface);
|
CHECK_FALSE(response["error"].get<std::string>().empty());
|
||||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
|
||||||
|
|
||||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
|
||||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
|
|||||||
{
|
{
|
||||||
PluginDescriptor descriptor;
|
PluginDescriptor descriptor;
|
||||||
descriptor.version = "1.3.0";
|
descriptor.version = "1.3.0";
|
||||||
descriptor.latest_version = "1.3.0";
|
descriptor.latest_version = "1.4.0";
|
||||||
PluginChangelog changelog;
|
PluginChangelog changelog;
|
||||||
changelog.version = "1.2.0";
|
changelog.version = "1.2.0";
|
||||||
descriptor.changelog.push_back(changelog);
|
descriptor.changelog.push_back(changelog);
|
||||||
|
|
||||||
CHECK(descriptor.latest_available_version() == "1.3.0");
|
CHECK(descriptor.latest_available_version() == "1.4.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
|
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
|
||||||
|
|||||||
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
|
|||||||
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
|
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
|
||||||
CHECK(c.shape(0) == 4);
|
CHECK(c.shape(0) == 4);
|
||||||
|
|
||||||
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
|
// lslices are derived: make_slices() re-derives them from the region slices and refreshes the
|
||||||
|
// bbox cache.
|
||||||
TestLayer layer;
|
TestLayer layer;
|
||||||
|
layer.add_region(nullptr);
|
||||||
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
|
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
|
||||||
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
|
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||||
|
REQUIRE(layer.lslices.empty());
|
||||||
ly.attr("make_slices")();
|
ly.attr("make_slices")();
|
||||||
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
|
REQUIRE(layer.lslices.size() == 1);
|
||||||
|
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
|
||||||
|
REQUIRE(layer.lslices_bboxes.size() == 1);
|
||||||
|
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
|
||||||
|
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
|
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
|
||||||
|
|||||||
Reference in new issue
Block a user