Compare commits

..
Author SHA1 Message Date
Ian Chua 6c791532c6 fix: update api to allow requesting network permissions 2026-10-20 01:15:14 +08:00
Lam Wei Lun 4c2ae6d202 Keep Auto Drop off for projects saved by 2.4.x (#16633)
2.4.0 to 2.4.2 wrote auto_drop on each build item, and 2.4.x objects
carry no auto_drop attribute in model_settings.config. The importer only
read the object attribute and treated a missing one as on, so floating
objects were dropped to the bed on load and the flag was rewritten as on
on the next save.

Record object ids with an item auto_drop="0" and use that as the fallback
when the object has no attribute. The attribute's presence is now tracked
so the fallback applies only when it is absent. Any item set to off turns
auto drop off for the object, since the flag is shared by all instances.
2026-10-19 16:08:59 +08:00
HanifKoh ac022b56ad Wait for the Close Frame in the Moonraker Status Stream Test (#16635)
The fake printer counts the agent's close frame in its read handler, but
Beast answers the close before that read completes, so stop() can return
and the test can read the counter before it is incremented. On a loaded
runner the check saw 0.

Wait for the counter like the other fake-printer counters do, then keep
the exact-count check.
2026-10-19 16:06:41 +08:00
28 changed files with 1107 additions and 632 deletions

No files matched your search

@@ -0,0 +1,39 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Network Permission Probe"
# description = "Tests host-based network permission prompts and matching"
# author = "OrcaSlicer"
# version = "0.1.0"
# ///
"""Local probe for the orca.request_permissions(network=...) API.
Install this file as a local plugin, approve https://example.com, then run the
script capability more than once. Each run changes the query string; the saved
scheme/host grant should prevent another prompt for that same host.
"""
import time
import urllib.request
import orca
class NetworkPermissionProbe(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Network Permission Probe"
def execute(self):
# User-triggered GET to a reserved example domain; varying the query tests
# that permission matching is not tied to the complete URL.
url = f"https://example.com/?permission_probe={time.time_ns()}"
with urllib.request.urlopen(url, timeout=10) as response:
return orca.ExecutionResult.success(f"Network permission probe: HTTP {response.status}")
@orca.plugin
class NetworkPermissionProbePackage(orca.base):
def register_capabilities(self):
orca.request_permissions(network=["https://example.com"])
orca.register_capability(NetworkPermissionProbe)
+13 -3
View File
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
MetadataList metadata; MetadataList metadata;
VolumeMetadataList volumes; VolumeMetadataList volumes;
bool auto_drop { true }; std::optional<bool> auto_drop;
}; };
struct CutObjectInfo struct CutObjectInfo
@@ -1208,6 +1208,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
//IdToGeometryMap m_orig_geometries; // backup & restore //IdToGeometryMap m_orig_geometries; // backup & restore
CurrentConfig m_curr_config; CurrentConfig m_curr_config;
IdToMetadataMap m_objects_metadata; IdToMetadataMap m_objects_metadata;
std::set<int> m_items_auto_drop_off; // object ids with a build item auto_drop="0" (2.4.x layout)
IdToCutObjectInfoMap m_cut_object_infos; IdToCutObjectInfoMap m_cut_object_infos;
IdToLayerHeightsProfileMap m_layer_heights_profiles; IdToLayerHeightsProfileMap m_layer_heights_profiles;
IdToLayerConfigRangesMap m_layer_config_ranges; IdToLayerConfigRangesMap m_layer_config_ranges;
@@ -1463,6 +1464,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_objects.clear(); m_objects.clear();
m_instances.clear(); m_instances.clear();
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_curr_metadata_name.clear(); m_curr_metadata_name.clear();
m_curr_characters.clear(); m_curr_characters.clear();
@@ -1502,6 +1504,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_config.object_id = -1; m_curr_config.object_id = -1;
m_curr_config.volume_id = -1; m_curr_config.volume_id = -1;
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_layer_heights_profiles.clear(); m_layer_heights_profiles.clear();
m_layer_config_ranges.clear(); m_layer_config_ranges.clear();
m_brim_ear_points.clear(); m_brim_ear_points.clear();
@@ -2257,11 +2260,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
ObjectMetadata::VolumeMetadataList volumes; ObjectMetadata::VolumeMetadataList volumes;
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr; ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
// 2.4.x stored auto_drop on build items, shared by all instances of an object; any "off" wins
const bool legacy_auto_drop = !m_items_auto_drop_off.count(object.first.second);
IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second); IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second);
if (obj_metadata != m_objects_metadata.end()) { if (obj_metadata != m_objects_metadata.end()) {
// config data has been found, this model was saved using slic3r pe // config data has been found, this model was saved using slic3r pe
model_object->auto_drop = obj_metadata->second.auto_drop; model_object->auto_drop = obj_metadata->second.auto_drop.value_or(legacy_auto_drop);
// apply object's name and config data // apply object's name and config data
for (const Metadata& metadata : obj_metadata->second.metadata) { for (const Metadata& metadata : obj_metadata->second.metadata) {
@@ -2279,6 +2285,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
} }
else { else {
// config data not found, this model was not saved using slic3r pe // config data not found, this model was not saved using slic3r pe
model_object->auto_drop = legacy_auto_drop;
// add the entire geometry as the single volume to generate // add the entire geometry as the single volume to generate
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1); //volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
@@ -4121,6 +4128,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR); std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR);
Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR)); Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR));
int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR); int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR);
if (const char* auto_drop = bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR); auto_drop != nullptr && !::atoi(auto_drop))
m_items_auto_drop_off.insert(object_id);
return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1); return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1);
} }
@@ -4489,7 +4498,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR); // int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
ObjectMetadata metadata; ObjectMetadata metadata;
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR); if (bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR) != nullptr)
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
m_objects_metadata.insert({ object_id, std::move(metadata) }); m_objects_metadata.insert({ object_id, std::move(metadata) });
m_curr_config.object_id = object_id; m_curr_config.object_id = object_id;
return true; return true;
+461 -87
View File
@@ -5,10 +5,14 @@
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h #include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
#include "libslic3r_version.h" #include "libslic3r_version.h"
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -146,6 +150,205 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
// HTTP and raw-socket events are governed by the same network permission: a declared host may be
// reached through either layer, and the layers nest for one plugin action (urllib -> http.client
// -> socket).
static bool is_network_category(AuditEventCategory category)
{
return category == AuditEventCategory::Http || category == AuditEventCategory::Socket;
}
// The only network events whose target can be a resolved address instead of the host the plugin
// asked for. They may fall back to a network-approved call site; every other network event must
// name a host or URL the hook can match directly.
static bool is_resolved_address_event(const std::string& event_name)
{
return event_name == "socket.connect" || event_name == "socket.sendto";
}
// ---------------------------------------------------------------------------
// Network permission identity
// ---------------------------------------------------------------------------
static std::string ascii_lower(std::string value)
{
boost::algorithm::to_lower(value);
return value;
}
static std::string trim_ascii_space(std::string value)
{
boost::algorithm::trim(value);
return value;
}
// Canonical storage keeps IPv6 literals bracketed (https://[::1]), while a socket address
// exposes the bare literal (::1). Compare them without the brackets.
static std::string without_ipv6_brackets(std::string host)
{
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
host = host.substr(1, host.size() - 2);
return host;
}
// Returns the host part of a URL authority, dropping userinfo and port. IPv6 literals keep their
// brackets so that a URL and a bare address compare consistently.
static std::string host_from_authority(std::string authority)
{
const size_t at = authority.rfind('@');
if (at != std::string::npos)
authority.erase(0, at + 1);
if (authority.empty())
return {};
if (authority.front() == '[') {
const size_t close = authority.find(']');
if (close == std::string::npos)
return {};
authority.erase(close + 1);
return ascii_lower(authority);
}
const size_t colon = authority.find(':');
if (colon != std::string::npos)
authority.erase(colon);
if (authority.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(authority);
}
// Extracts a host from a target that is not a URL: "host", "host:443", "[::1]:443", "('host',
// 443)" (the form older releases persisted for socket address events), or "'host'".
static std::string network_host_from_target(const std::string& target)
{
std::string value = trim_ascii_space(target);
if (value.empty())
return {};
if (value.front() == '(') {
// Legacy persisted address tuple: ("host", 443) or ("host", 443, 0, 0).
const size_t quote = value.find_first_of("'\"");
if (quote != std::string::npos) {
const char delimiter = value[quote];
const size_t end = value.find(delimiter, quote + 1);
if (end == std::string::npos)
return {};
value = value.substr(quote + 1, end - quote - 1);
} else {
value = value.substr(1);
const size_t comma = value.find(',');
if (comma != std::string::npos)
value.erase(comma);
}
} else if (value.front() == '[') {
const size_t close = value.find(']');
if (close == std::string::npos)
return {};
value.erase(close + 1);
} else {
// Strip a ":port" suffix, but leave a bare IPv6 literal ("::1") intact.
const size_t colon = value.find(':');
if (colon != std::string::npos && value.find(':', colon + 1) == std::string::npos)
value.erase(colon);
}
value = trim_ascii_space(value);
if (value.empty() || value.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(value);
}
std::string PluginAuditManager::normalize_network_url(const std::string& url)
{
const size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos || scheme_end == 0)
return {};
const std::string scheme = ascii_lower(url.substr(0, scheme_end));
if (scheme != "http" && scheme != "https")
return {};
const size_t authority_start = scheme_end + 3;
const size_t authority_end = url.find_first_of("/?#", authority_start);
const std::string authority = url.substr(authority_start, authority_end == std::string::npos
? std::string::npos
: authority_end - authority_start);
const std::string host = host_from_authority(authority);
if (host.empty())
return {};
return scheme + "://" + host;
}
bool PluginAuditManager::network_permission_matches(const std::vector<std::string>& granted,
const std::string& target)
{
if (target.empty())
return false;
const bool target_is_url = target.find("://") != std::string::npos;
const std::string target_url = normalize_network_url(target);
if (target_is_url && target_url.empty()) {
// A non-http(s) URL has no network identity we can generalize; only an exact grant
// recorded by an earlier approval stays valid, so an unrelated scheme never matches.
return std::find(granted.begin(), granted.end(), target) != granted.end();
}
const std::string target_host =
target_is_url ? std::string{} : without_ipv6_brackets(network_host_from_target(target));
if (!target_is_url && target_host.empty())
return false;
for (const std::string& entry : granted) {
// Preserve an exact grant recorded by an older release, e.g. a full URL with a query.
if (entry == target)
return true;
const std::string entry_url = normalize_network_url(entry);
if (target_is_url) {
if (entry_url == target_url)
return true;
continue;
}
if (!entry_url.empty()) {
// Bare hosts carry no scheme: match the host part of a scheme://host grant.
const std::string entry_host = entry_url.substr(entry_url.find("://") + 3);
if (without_ipv6_brackets(entry_host) == target_host)
return true;
} else if (without_ipv6_brackets(network_host_from_target(entry)) == target_host) {
// Legacy persisted address tuples, e.g. "('host', 443)".
return true;
}
}
return false;
}
bool PluginAuditManager::is_ip_address_target(const std::string& target)
{
if (target.empty() || target.find("://") != std::string::npos)
return false;
std::string host = target;
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
host = host.substr(1, host.size() - 2);
// Drop an IPv6 zone identifier before parsing; it is not part of the address literal.
const size_t zone = host.find('%');
if (zone != std::string::npos)
host.erase(zone);
boost::system::error_code ec;
boost::asio::ip::make_address(host, ec);
return !ec;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -471,6 +674,39 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
return check_path_access(boost::filesystem::path(path_str), is_write); return check_path_access(boost::filesystem::path(path_str), is_write);
} }
namespace {
// Builds and shows the modal confirmation. Must run on the GUI thread.
bool show_permission_dialog(const wxString& message, const wxString& title)
{
wxMessageDialog dialog(nullptr, message, title, wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Shows a permission prompt on the GUI thread and waits for the answer. The caller may be the
// plugin-load worker, so off the main thread the dialog is marshaled with CallAfter.
bool ask_permission(const wxString& message)
{
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return show_permission_dialog(message, _L("Plugin permissions"));
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, message]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) {
result->set_value(false);
return;
}
result->set_value(show_permission_dialog(message, _L("Plugin permissions")));
});
return future.get();
}
} // namespace
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths) const std::vector<std::string>& paths)
{ {
@@ -492,60 +728,80 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (missing.empty()) if (missing.empty())
return true; return true;
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths)))
return false; return false;
auto show_dialog = [&descriptor, &missing]() { seed_install_metadata(state, descriptor);
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
};
bool granted = false;
if (wxIsMainThread()) {
granted = show_dialog();
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end()); state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
} }
bool PluginAuditManager::request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls)
{
if (plugin_key.empty() || urls.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
// Reduce every request to scheme://host before comparing or persisting it, so a URL with a
// path or query cannot create a second grant for the same host.
std::vector<std::string> requested;
for (const std::string& url : urls) {
const std::string normalized = normalize_network_url(url);
if (normalized.empty()) {
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Invalid network permission request for plugin " << plugin_key << ": " << url;
return false;
}
if (std::find(requested.begin(), requested.end(), normalized) == requested.end())
requested.push_back(normalized);
}
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> missing;
for (const std::string& entry : requested) {
if (!network_permission_matches(state.permissions.network_http, entry) &&
!network_permission_matches(state.permissions.network_socket, entry))
missing.push_back(entry);
}
if (missing.empty())
return true;
wxString requested_hosts;
for (const std::string& entry : missing)
requested_hosts += wxString::FromUTF8(entry.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests network access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_hosts)))
return false;
seed_install_metadata(state, descriptor);
// A declared host is reachable through both HTTP and raw socket events. Recording the grant in
// both existing lists keeps the sidecar self-describing and means either event layer matches
// it without relying on the other list.
for (const std::string& entry : missing) {
if (std::find(state.permissions.network_http.begin(), state.permissions.network_http.end(), entry) ==
state.permissions.network_http.end())
state.permissions.network_http.push_back(entry);
if (std::find(state.permissions.network_socket.begin(), state.permissions.network_socket.end(), entry) ==
state.permissions.network_socket.end())
state.permissions.network_socket.push_back(entry);
}
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation) void PluginAuditManager::report_violation(const AuditViolation& violation)
{ {
m_last_violation = violation; m_last_violation = violation;
@@ -577,13 +833,38 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const const std::vector<std::string>& call_site_ids) const
{
return has_call_site(m_approved_call_sites, plugin_key, call_site_ids);
}
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
record_call_sites(m_approved_call_sites, plugin_key, call_site_ids);
}
bool PluginAuditManager::has_approved_network_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
return has_call_site(m_approved_network_call_sites, plugin_key, call_site_ids);
}
void PluginAuditManager::record_approved_network_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
record_call_sites(m_approved_network_call_sites, plugin_key, call_site_ids);
}
bool PluginAuditManager::has_call_site(const CallSiteRegistry& registry,
const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{ {
if (plugin_key.empty() || call_site_ids.empty()) if (plugin_key.empty() || call_site_ids.empty())
return false; return false;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key); auto it = registry.find(plugin_key);
if (it == m_approved_call_sites.end()) if (it == registry.end())
return false; return false;
for (const auto& id : call_site_ids) for (const auto& id : call_site_ids)
@@ -592,14 +873,15 @@ bool PluginAuditManager::has_approved_ancestor(const std::string& p
return false; return false;
} }
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key, void PluginAuditManager::record_call_sites(CallSiteRegistry& registry,
const std::vector<std::string>& call_site_ids) const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{ {
if (plugin_key.empty() || call_site_ids.empty()) if (plugin_key.empty() || call_site_ids.empty())
return; return;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = m_approved_call_sites[plugin_key]; auto& approved = registry[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end()); approved.insert(call_site_ids.begin(), call_site_ids.end());
} }
@@ -616,6 +898,39 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t index)
return PyTuple_GET_ITEM(args, index); return PyTuple_GET_ITEM(args, index);
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
// Converts a str/bytes leaf to text, clearing a failed conversion. Returns false when `object` is
// neither str nor bytes, so callers can apply their own fallback.
bool python_text(PyObject* object, std::string& out)
{
if (PyUnicode_Check(object)) {
out = python_unicode(object);
return true;
}
if (PyBytes_Check(object)) {
const char* value = PyBytes_AsString(object);
if (!value) {
PyErr_Clear();
out.clear();
return true;
}
out = value;
return true;
}
return false;
}
std::string python_path(PyObject* object) std::string python_path(PyObject* object)
{ {
if (!object) if (!object)
@@ -627,26 +942,12 @@ std::string python_path(PyObject* object)
return {}; return {};
} }
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object); std::string result;
std::string result = path ? path : ""; python_text(path_object, result);
Py_DECREF(path_object); Py_DECREF(path_object);
if (!path)
PyErr_Clear();
return result; return result;
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
std::string python_str(PyObject* object) std::string python_str(PyObject* object)
{ {
if (!object) if (!object)
@@ -663,6 +964,27 @@ std::string python_str(PyObject* object)
return result; return result;
} }
// Network audit events expose different shapes for the same thing: a URL, a bare host, or an
// address tuple such as ("host", 443). Reduce the argument to the host it names, leaving the
// scheme and port to be judged by the permission matcher. None / an empty tuple has no target.
std::string python_network_host(PyObject* object)
{
if (!object || object == Py_None)
return {};
if (PyTuple_Check(object)) {
if (PyTuple_GET_SIZE(object) == 0)
return {};
return python_network_host(PyTuple_GET_ITEM(object, 0));
}
std::string text;
if (python_text(object, text))
return text;
return python_str(object);
}
std::vector<std::string> call_site_identities(const std::string& plugin_root) std::vector<std::string> call_site_identities(const std::string& plugin_root)
{ {
std::vector<std::string> ids; std::vector<std::string> ids;
@@ -766,8 +1088,10 @@ 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()) {
const bool network_event = is_network_category(category);
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)); std::string value = network_event ? python_network_host(tuple_item(args, index))
: python_str(tuple_item(args, index));
if (!value.empty()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -797,6 +1121,20 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
} }
// A declared or previously approved network grant may live in either network list: an HTTP grant
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
// the same host.
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
{
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
}
bool has_any_network_permission(const PluginPermissions& permissions)
{
return !permissions.network_http.empty() || !permissions.network_socket.empty();
}
bool persist_permission(const std::string& plugin_key, bool persist_permission(const std::string& plugin_key,
PluginInstallState& state, PluginInstallState& state,
std::vector<std::string>& permission_list, std::vector<std::string>& permission_list,
@@ -806,15 +1144,7 @@ bool persist_permission(const std::string& plugin_key,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false; return false;
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata seed_install_metadata(state, descriptor);
// needed by the writer so clicking Yes still creates the JSON file.
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
if (!has_permission(permission_list, target)) if (!has_permission(permission_list, target))
permission_list.push_back(target); permission_list.push_back(target);
@@ -893,11 +1223,10 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
for (const auto& target : unresolved) for (const auto& target : unresolved)
target_list += wxString::FromUTF8(target.c_str()) + "\n"; target_list += wxString::FromUTF8(target.c_str()) + "\n";
wxMessageDialog dialog(nullptr, return show_permission_dialog(
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()),
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()), target_list, unresolved.size()),
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING); _L("Plugin permission request"));
return dialog.ShowModal() == wxID_YES;
} }
// 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
@@ -957,7 +1286,30 @@ int decide_audited_event(PluginAuditManager& mgr,
const std::vector<std::string>& call_site_ids) const std::vector<std::string>& call_site_ids)
{ {
std::vector<std::string> unresolved = targets; std::vector<std::string> unresolved = targets;
if (permission_list) { if (is_network_category(category)) {
if (targets.empty()) {
// socket.__new__ and http.client.send carry no host. Any network grant already lets
// the plugin open a connection, and every host-bearing event still matches its own
// grant, so the targetless events are covered by an existing grant instead of
// prompting every run. Deliberately do not record a call site here: a targetless
// approval must not become an ancestor that later network events could use to skip
// their own host check.
if (has_any_network_permission(state.permissions))
return 0;
} else {
unresolved.clear();
for (const auto& target : targets)
if (!has_network_permission(state.permissions, target))
unresolved.push_back(target);
if (unresolved.empty()) {
// Remember the stdlib frames for this approved host. The network ancestor cache
// is only consulted for a resolved-IP socket.connect/sendto, so this cannot
// suppress a later hostname or URL event.
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
return 0;
}
}
} else if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& target : targets) for (const auto& target : targets)
if (!has_permission(*permission_list, target)) if (!has_permission(*permission_list, target))
@@ -973,6 +1325,12 @@ int decide_audited_event(PluginAuditManager& mgr,
for (const auto& target : unresolved) for (const auto& target : unresolved)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, target);
// A target-bearing network approval may cover the resolved address of the same connection.
// A targetless approval does not: it carries no host, so it must not create an ancestor that
// a later hostname/URL check could be skipped by (and the network cache is IP-only anyway).
if (is_network_category(category) && !targets.empty())
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
mgr.record_approved_call_sites(plugin_key, call_site_ids); mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0; return 0;
} }
@@ -1024,12 +1382,28 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
PluginDescriptor plugin_descriptor; PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), 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 // Some plugins call other audited events, e.g. urllib.request will call socket.connect.
// was already approved, socket.connect won't trigger another permission dialog. // Collect the stdlib frames now so an approved network host can cover the resolved address
// of the same connection without covering another host.
const std::vector<std::string> call_site_ids = const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root); PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
// A general call-site approval may cover nested events for the same logical action. Network
// events use a separate rule: a hostname/URL target must match its own grant even when a
// parent event was approved, and targetless events are handled by the any-grant rule. Only a
// resolved-address connect/sendto (target is an IP literal) may use an approval recorded for
// an earlier network host event, because its own target carries no hostname to match.
if (is_network_category(event_type)) {
const bool all_resolved_addresses =
!targets.empty() && std::all_of(targets.begin(), targets.end(), [](const std::string& target) {
return PluginAuditManager::is_ip_address_target(target);
});
if (is_resolved_address_event(event_name) && all_resolved_addresses &&
mgr->has_approved_network_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
} else if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) {
return 0; return 0;
}
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the // 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 // plugin system's own data_dir() tree (which holds each plugin's storage folder and the
+51 -2
View File
@@ -138,6 +138,33 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key, bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths); const std::vector<std::string>& paths);
// Ask the user to grant network access to the given http(s) URLs. Each URL is reduced to its
// scheme and host before prompting and persisting, so one approval covers every path, query
// and port on that host. The grant is written to both network_http and network_socket: the
// same declared host can surface through HTTP events (urllib/http.client) and raw socket
// events. Like request_filesystem_read_permissions, the request may originate on a plugin
// load worker; denial aborts the plugin load without adding a permission.
bool request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls);
// Canonical scheme://host form used to store and match network permissions. Returns an empty
// string unless `url` is an absolute http(s) URL with a host. The path, query, fragment,
// userinfo and port are discarded; scheme and host are lowercased.
static std::string normalize_network_url(const std::string& url);
// True when a granted scheme://host entry covers `target`. `target` is a URL (urllib.Request),
// a bare host (http.client.connect, socket.getaddrinfo) or an address as exposed by the audit
// event (a hostname, a host:port pair, or a legacy persisted tuple such as "('host', 443)").
// URL targets must match both scheme and host. A bare host carries no scheme, so it matches
// the host part of any grant; it never matches a different host.
static bool network_permission_matches(const std::vector<std::string>& granted,
const std::string& target);
// True when `target` is a bare IPv4/IPv6 literal (optionally bracketed). A network event
// carrying an IP literal has no hostname the audit hook can match, so it is the only kind of
// network event allowed to use an approved network call-site ancestor.
static bool is_ip_address_target(const std::string& target);
void report_violation(const AuditViolation& violation); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const; bool audit_denial_pending() const;
void clear_audit_denial(); void clear_audit_denial();
@@ -151,9 +178,20 @@ public:
// is recorded here by (filename, function, first line) identity. A later event whose own // is recorded here by (filename, function, first line) identity. A later event whose own
// ancestor chain still contains one of those frames is the same logical action seen from // ancestor chain still contains one of those frames is the same logical action seen from
// a deeper layer, so it is auto-approved instead of prompting again. // a deeper layer, so it is auto-approved instead of prompting again.
//
// Network events do not use this general cache: see the network call-site cache below.
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const; bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids); void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
// --- network call-site cache ---
// Separate from the general cascade above so a network approval can only suppress a later
// network event with no hostname to check: a resolved address (socket.connect/sendto with an
// IP literal). Hostname and URL events are never auto-approved by an ancestor; they must
// match their own grant. Targetless events are handled by the "any network grant" rule and
// never record an entry here.
bool has_approved_network_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
void record_approved_network_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
bool verbose_events = true; bool verbose_events = true;
private: private:
@@ -170,12 +208,23 @@ private:
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: the deny and ancestor queries are const but must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
using CallSiteRegistry = std::unordered_map<std::string, std::unordered_set<std::string>>;
// Shared lookup/insert mechanics for the two registries below. The caller selects the
// registry, so the general and network approvals remain independent.
bool has_call_site(const CallSiteRegistry& registry, const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const;
void record_call_sites(CallSiteRegistry& registry, const std::string& plugin_key,
const std::vector<std::string>& call_site_ids);
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 CallSiteRegistry m_approved_call_sites;
CallSiteRegistry m_approved_network_call_sites;
}; };
// 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
+27 -15
View File
@@ -832,6 +832,29 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
} }
} }
namespace {
// The single descriptor -> sidecar metadata mapping. Prefer installed_version (fetched at install
// time and preserved across rewrites) over a possibly stale manifest version.
void apply_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
}
} // namespace
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
if (!state.plugin_name.empty())
return;
apply_install_metadata(state, entry);
state.enabled = true;
}
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -874,16 +897,9 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted // Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
// during register_capabilities() or by a previous runtime audit prompt. // during register_capabilities() or by a previous runtime audit prompt.
read_install_state(plugin_dir, state); read_install_state(plugin_dir, state);
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; apply_install_metadata(state, entry);
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud state.enabled = enabled;
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header state.capabilities = capabilities;
// never overwrites the source-of-truth version. Fall back to the manifest version for
// first-time/local installs where installed_version is not yet populated.
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = enabled;
state.capabilities = capabilities;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
@@ -894,11 +910,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags // permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
// while preserving permissions once the package is loaded, via the overload above. // while preserving permissions once the package is loaded, via the overload above.
PluginInstallState state; PluginInstallState state;
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; seed_install_metadata(state, entry);
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = true;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
+7
View File
@@ -184,12 +184,19 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
// Canonical writer: emits the .install_state.json schema for the given state. // Canonical writer: emits the .install_state.json schema for the given state.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. // Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
const std::vector<std::pair<std::string, bool>>& capabilities); const std::vector<std::pair<std::string, bool>>& capabilities);
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). // Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry);
// Fill the descriptor-derived install metadata into a sidecar state that does not describe an
// installed plugin yet, and mark it enabled. Leaves permissions and capabilities untouched.
// Single source for the metadata mapping used by the install writer and by the audit prompt path
// that creates a sidecar on demand.
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry);
// Reads install state back into the descriptor; plugin_key is always derived. Returns whether the // Reads install state back into the descriptor; plugin_key is always derived. Returns whether the
// sidecar was present and valid. // sidecar was present and valid.
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
+2 -6
View File
@@ -1180,12 +1180,8 @@ void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin
const boost::filesystem::path root(descriptor.plugin_root); const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state; PluginInstallState state;
if (!read_install_state(root, state)) { if (!read_install_state(root, state))
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; seed_install_metadata(state, descriptor);
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
}
state.enabled = false; state.enabled = false;
if (!write_install_state(root, state)) if (!write_install_state(root, state))
return; return;
+66 -9
View File
@@ -50,6 +50,7 @@ std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities; std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package; std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions; std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
struct PluginInstanceHandle struct PluginInstanceHandle
{ {
// The C++ plugin interface points into a Python object. Keep both alive through one // The C++ plugin interface points into a Python object. Keep both alive through one
@@ -89,6 +90,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
} }
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
} // namespace } // namespace
@@ -114,6 +116,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by // From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin. // Python code on this thread are attributed to this plugin.
@@ -145,6 +148,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
g_pending_capabilities.erase(capture_key); g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key); g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key); g_pending_fs_read_permissions.erase(capture_key);
g_pending_network_permissions.erase(capture_key);
}; };
try { try {
@@ -200,6 +204,30 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
return {}; return {};
} }
} }
std::vector<std::string> network_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_network_permissions.find(capture_key);
if (it != g_pending_network_permissions.end()) {
network_permissions = std::move(it->second);
g_pending_network_permissions.erase(it);
}
}
if (!network_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_network_permissions(plugin_key, network_permissions);
}
if (!granted) {
error = _u8L("Plugin network permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
return {};
}
}
} catch (py::error_already_set& err) { } catch (py::error_already_set& err) {
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
@@ -323,6 +351,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == plugin_key)
@@ -350,6 +379,7 @@ void PythonPluginBridge::clear_pending_captures()
} }
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
return; return;
} }
@@ -359,6 +389,7 @@ void PythonPluginBridge::clear_pending_captures()
g_pending_capabilities.clear(); g_pending_capabilities.clear();
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
} }
@@ -558,24 +589,50 @@ void bind_python_api(pybind11::module_& m)
m.def( m.def(
"request_permissions", "request_permissions",
[](const std::vector<std::string>& fs_read) { [](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context"); throw py::value_error("request_permissions() called outside plugin discovery context");
std::lock_guard<std::mutex> lock(g_registry_mutex); // Validate and canonicalize everything before touching the pending registry, so a
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key]; // partially invalid call cannot leave a half-recorded request behind.
std::vector<std::string> normalized_network;
normalized_network.reserve(network.size());
for (const std::string& url : network) {
const std::string normalized = PluginAuditManager::normalize_network_url(url);
if (normalized.empty())
throw py::value_error(
"request_permissions(network=...) requires an http:// or https:// URL with a host");
if (std::find(normalized_network.begin(), normalized_network.end(), normalized) == normalized_network.end())
normalized_network.push_back(normalized);
}
for (const std::string& path : fs_read) { for (const std::string& path : fs_read) {
if (path.empty()) if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
if (std::find(requested.begin(), requested.end(), path) == requested.end()) }
requested.push_back(path);
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (std::find(requested_fs.begin(), requested_fs.end(), path) == requested_fs.end())
requested_fs.push_back(path);
}
if (!normalized_network.empty()) {
auto& requested_network = g_pending_network_permissions[g_active_plugin_key];
for (const std::string& entry : normalized_network) {
if (std::find(requested_network.begin(), requested_network.end(), entry) == requested_network.end())
requested_network.push_back(entry);
}
} }
}, },
py::arg("fs_read") = std::vector<std::string>{}, py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module. R"pbdoc(Request permissions while OrcaSlicer loads your module.
The host presents the request to the user after register_capabilities() returns. Denying the `fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
request aborts the plugin load.)pbdoc"); scheme and host, so one grant covers every path, query and port on that host. The host presents
the requests to the user after register_capabilities() returns. Denying a request aborts the
plugin load.)pbdoc");
m.def("plugin", [](py::object cls) { m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
+5 -39
View File
@@ -1,7 +1,6 @@
#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>
@@ -18,48 +17,15 @@
using namespace Slic3r; using namespace Slic3r;
using namespace Slic3r::Test; using namespace Slic3r::Test;
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards. // The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
// 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) }, {
{ "close_fan_the_first_x_layers", fan_off_layers }, { "cooling", true },
{ "full_fan_speed_layer", 0 }, { "close_fan_the_first_x_layers", 5 },
{ "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
+10 -9
View File
@@ -86,9 +86,10 @@ 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
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out // TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
// and a single line crosses the middle, ending within half a spacing of the edges. // This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.)); // ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
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();
} }
@@ -147,9 +148,10 @@ 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
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path. // TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing; // This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01)); // ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
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") {
@@ -166,9 +168,8 @@ 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, PI / 4.0 }) { for (auto angle : { 0.0, 45.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);
} }
@@ -221,7 +222,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
}; };
Slic3r::ExPolygon expolygon(points); Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true); REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
} }
} }
+18 -42
View File
@@ -123,58 +123,38 @@ 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 },
// Shell counts alone decide which layers are solid. { "layer_height", 0.25 }, // get a known number of layers
{ "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);
// True when the layer has fill surfaces and all of them are solid. // Precondition: Ensure that the model has 2 solid top layers (79, 78)
auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) { // and one solid bottom layer (0).
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));
bool has_fill = false; // iterate over all of the regions in the layer
for (const LayerRegion *region : layer.regions()) for (const LayerRegion *region : layer.regions()) {
for (const Surface &surface : region->fill_surfaces.surfaces) { // for each region, iterate over the fill 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)
if (! surface.is_solid()) CHECK(surface.is_solid());
return true; }
return false;
}; };
print.process(); print.process();
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0). test_is_solid_infill(0, 0); // should be solid
REQUIRE(is_solid_infill(0, 0)); test_is_solid_infill(0, 79); // should be solid
REQUIRE(is_solid_infill(0, 79)); test_is_solid_infill(0, 78); // should be solid
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.") {
CHECK(is_solid_infill(0, 0)); test_is_solid_infill(0, 0);
} }
AND_THEN("Print object has 3 top solid layers") { AND_THEN("Print object has 3 top solid layers") {
CHECK(is_solid_infill(0, 79)); test_is_solid_infill(0, 79);
CHECK(is_solid_infill(0, 78)); test_is_solid_infill(0, 78);
CHECK(is_solid_infill(0, 77)); test_is_solid_infill(0, 77);
CHECK(has_sparse_infill(0, 76));
}
AND_THEN("Middle layers keep sparse infill") {
CHECK(has_sparse_infill(0, 40));
} }
} }
} }
@@ -692,18 +672,14 @@ 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("; sparse_infill_density =") == std::string::npos); CHECK(gcode.find("; fill_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
+10 -20
View File
@@ -1,8 +1,6 @@
#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"
@@ -164,29 +162,21 @@ 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}};
TriangleMesh mesh(vertices, facets); auto cube = make_cube();
// 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") {
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0}); // At Z = 10 we have a top horizontal surface.
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
THEN( "its area is included") { THEN( "its area is included") {
REQUIRE(slices.at(0).size() == 1); REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).size() == 1); REQUIRE(slices.at(1).at(0).area() > 0);
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") {
mesh.mirror_z(); cube.mirror_z();
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0}); std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") { THEN( "it is sliced properly (mirrored bottom plane area is included)") {
REQUIRE(slices.at(0).size() == 1); REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).size() == 1); REQUIRE(slices.at(1).at(0).area() > 0);
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,18 +319,9 @@ 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)
+40 -41
View File
@@ -505,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
REQUIRE_FALSE(loaded); REQUIRE_FALSE(loaded);
} }
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
{
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
ScopedTemporaryFile temp(".3mf");
{
Model model;
ModelObject* object = model.add_object();
object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
object->auto_drop = false;
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
CHECK_FALSE(model.objects.front()->auto_drop);
}
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]") TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
{ {
// The importer queues at most 100000 component references per object. Every reference besides the // The importer queues at most 100000 component references per object. Every reference besides the
@@ -1033,19 +1055,18 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
} }
} }
// A project without multi-nozzle metadata must load through the BBS importer and must not // A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
// fabricate a filament_volume_map. // importer and must not fabricate a filament_volume_map.
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") { SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; GIVEN("a project without any multi-nozzle 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;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
GIVEN("a legacy project with a bare model and no plate metadata") { PlateDataPtrs plates;
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;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") { WHEN("loaded through the BBS importer") {
bool loaded = false; bool loaded = false;
@@ -1053,32 +1074,10 @@ SCENARIO("Projects without multi-nozzle metadata load without a per-filament vol
&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("the model loads") { THEN("it does not crash and invents no per-filament volume map") {
REQUIRE(loaded); for (PlateData* p : plates) {
REQUIRE_FALSE(model.objects.empty()); REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
} }
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);
} }
@@ -2049,8 +2048,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 loaded value must parse back with every extended field intact. // The value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]); nlohmann::json entries = nlohmann::json::parse(material_keys_json);
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);
+93 -118
View File
@@ -44,7 +44,6 @@
#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>
@@ -54,7 +53,6 @@
#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>
@@ -774,10 +772,16 @@ 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]")
{ {
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles. std::string font = resources_dir().empty()
const std::string font = ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string(); : resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
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"
@@ -2849,10 +2853,9 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
CadDocument doc; CadDocument doc;
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane // Build a body so we have a face to reference for FaceAndDirection.
// direction, so the frame has to fall back on coordsys_x_hint. int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle"); doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
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);
@@ -2862,33 +2865,34 @@ 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 the face normal +Z). // Deliberately non-perpendicular X hint (NOT orthogonal to 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());
const Vec3d X = css[0].x, Y = css[0].y; Vec3d X = css[0].x, Y = css[0].y;
// X is the hint projected into the face plane. // Orthonormality: each axis has unit length
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));
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9)); // Pairwise dot products are ~0
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9)); CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9)); // Z = X x Y (derived), also unit and perpendicular
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9)); Vec3d Z = X.cross(Y);
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]")
@@ -3913,23 +3917,17 @@ 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");
SECTION("construction edges form no profile, so the extrude fails") { REQUIRE(doc.recompute());
REQUIRE_FALSE(doc.recompute()); REQUIRE(doc.error.empty());
REQUIRE_FALSE(doc.error.empty()); REQUIRE(doc.bodies.size() == body_count_before);
} 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]")
@@ -4254,7 +4252,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("a v4 recipe keeps every field value", "[CadDocument]") TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
{ {
using Catch::Matchers::WithinRel; using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
@@ -4284,16 +4282,20 @@ TEST_CASE("a v4 recipe keeps every field value", "[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 - make_golden_doc_v1() must keep building exactly the tree " INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
"frozen in cad_recipe_v4.bin, so do not add or remove features there."); INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
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. // fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
// 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.
@@ -4548,48 +4550,34 @@ TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
} }
} }
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree --- // --- Layer 2: geometry check (optional — only if the document recomputes) ---
// The golden tree is serialization coverage and does not recompute as a whole: these // Build expected document and try to recompute it.
// features fail on the geometry they get, so they are left off in both trees. // ponytail: the field-value layer above is the real tripwire;
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375", // this layer is a bonus sanity check on the full recompute path.
"GoldenProject"};
CadDocument exp_doc = make_golden_doc_v1(); CadDocument exp_doc = make_golden_doc_v1();
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults bool exp_ok = exp_doc.recompute();
// (a v4 thread, for one, is not major-nominal). The reference has to match that. if (!exp_ok) {
const CadFeature def; INFO("Expected document from make_golden_doc_v1() could not recompute "
for (CadFeature& f : exp_doc.features) { "(complex feature tree). Field-value checks above are sufficient.");
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;
doc.deserialize_recipe(blob); bool ser_ok = doc.deserialize_recipe(blob);
REQUIRE(doc.features.size() == exp_doc.features.size());
for (size_t i = 0; i < exp_doc.features.size(); ++i) if (exp_ok && ser_ok) {
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0; REQUIRE(doc.error.empty());
const bool exp_built = exp_doc.recompute(); REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
INFO("reference error: " << exp_doc.error); for (size_t i = 0; i < doc.bodies.size(); ++i) {
REQUIRE(exp_built); double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
const bool built = doc.recompute(); double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
INFO("loaded error: " << doc.error); REQUIRE_THAT(v, WithinRel(ev, 1e-6));
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.");
} }
} }
@@ -5793,9 +5781,8 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents. int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate"); doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
@@ -5814,13 +5801,6 @@ 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]")
@@ -5843,9 +5823,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
// The connector sits on the cylinder's base, so the flip decides which side of the int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
// 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());
@@ -5859,7 +5837,8 @@ 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));
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4)); // Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
// 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]")
@@ -6546,19 +6525,23 @@ 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());
// The same assembly mated once without and once with flip, each in its own copy. // Fastened, NO flip
auto mated_com_z = [&](bool flip) { doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
CadDocument d = doc; REQUIRE(doc.recompute());
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate"); REQUIRE(doc.error.empty());
REQUIRE(d.recompute()); GProp_GProps nf_props;
REQUIRE(d.error.empty()); BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
REQUIRE(d.mate_conflicts.empty()); double nf_z = nf_props.CentreOfMass().Z();
GProp_GProps props;
BRepGProp::VolumeProperties(d.bodies[1].shape, props); doc.undo();
return props.CentreOfMass().Z();
}; // Fastened, WITH flip
const double nf_z = mated_com_z(false); doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
const double f_z = mated_com_z(true); REQUIRE(doc.recompute());
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));
@@ -7341,22 +7324,13 @@ 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");
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference. // A sheet passing straight through the solid: it has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet"); int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 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());
} }
@@ -7566,8 +7540,9 @@ 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.
REQUIRE(doc.bodies.size() == 1); CadDocument again = box();
CHECK_THAT(doc.body_mass_properties(0).volume, REQUIRE(again.recompute());
CHECK_THAT(again.body_mass_properties(0).volume,
Catch::Matchers::WithinAbs(solid, 1e-6)); Catch::Matchers::WithinAbs(solid, 1e-6));
} }
+10 -7
View File
@@ -75,10 +75,11 @@ 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") {
config.set("gcode_comments", true); THEN("A BadOptionTypeException exception is thrown.") {
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") {
@@ -116,10 +117,12 @@ 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.") {
config.set("max_bridge_length", 7.5); auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
THEN("A BadOptionValueException exception is thrown and the value does not change.") { THEN("A BadOptionTypeException exception is thrown.") {
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") {
@@ -195,7 +198,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 UnknownOptionException exception is thrown.") { THEN("A BadOptionTypeException 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);
+22 -6
View File
@@ -65,25 +65,41 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
} }
} }
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]") TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
{ {
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no // Regression for the "extra purge tower" on a belt printer with a mixed
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its // filament (MCTEST5). The belt purge prism is sized as
// components, so it must not count as a filament of its own. // n_islands = used_filaments.size() - 1
// 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 rather than // Only the mixed slot is assigned: it must still yield its two components,
// collapsing to nothing. // i.e. one island, rather than collapsing to zero.
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") {
+15 -16
View File
@@ -1334,17 +1334,12 @@ 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" }));
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset. // Normalizing rewrites config in place without flagging the preset dirty.
bundle.prints.select_preset_by_name("My Process", true); CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
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" });
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the // A system preset that already references the current name is left untouched (idempotent no-op).
// 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]")
@@ -1374,17 +1369,11 @@ 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" });
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset. // The rewrite does not flag the system preset dirty, and is idempotent.
bundle.filaments.select_preset_by_name("System Filament", true); CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
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]")
@@ -5577,6 +5566,16 @@ 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
+13 -5
View File
@@ -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,10 +78,18 @@ 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]")
{ {
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles. // Locate the bundled font; resources_dir() may be unset under ctest, so
const std::string font = // fall back to a cwd-relative path (tests run from the repo root).
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string(); std::string font = resources_dir().empty()
REQUIRE(std::ifstream(font).good()); ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
: 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());
+2 -3
View File
@@ -33,9 +33,8 @@ 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 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier // Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
// wins even though the midpoint is nearer. auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
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);
} }
+7 -4
View File
@@ -113,11 +113,14 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
REQUIRE(closed_wires(out) == 1); REQUIRE(closed_wires(out) == 1);
} }
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]") TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
{ {
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one // A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
// closed wire, not four disconnected segments. Its area is checked by the sign test below. // so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0); // for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
// 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,8 +60,9 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
} }
} // namespace } // namespace
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]") TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[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");
@@ -71,11 +72,15 @@ TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "
// 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);
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate auto has_multiple_nozzle = [](const std::vector<int> &values) {
// (any extruder_max_nozzle_count > 1) stays closed. return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
REQUIRE_FALSE(opt->values.empty()); };
for (int count : opt->values)
CHECK(count == 1); // Default for every existing printer: 1 nozzle per extruder => gate is closed.
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]")
+34 -75
View File
@@ -32,7 +32,6 @@
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
@@ -199,12 +198,8 @@ 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);
VD vd; BoostVD vd;
vd.construct_voronoi(lines.begin(), lines.end()); construct_voronoi(lines.begin(), lines.end(), &vd);
// 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(),
@@ -310,16 +305,9 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
} }
#endif #endif
VD vd; BoostVD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
vd.construct_voronoi(lines.begin(), lines.end()); construct_voronoi(lines.begin(), lines.end(), &vd);
// 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(),
@@ -359,8 +347,9 @@ 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?
// The input data contain self intersections, so no valid Voronoi diagram can be built from it. // Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]") // This test is suppressed.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
{ {
Lines lines = { Lines lines = {
{ { 260500,1564400 }, { 261040,1562960 } }, { { 260500,1564400 }, { 261040,1562960 } },
@@ -1338,13 +1327,18 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
} }
#endif #endif
VD vd; BoostVD vd;
vd.construct_voronoi(lines.begin(), lines.end()); construct_voronoi(lines.begin(), lines.end(), &vd);
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
// input does not repair it. It must be reported as invalid, which is what MedialAxis::build for (const auto& edge : vd.edges())
// checks before falling back to a morphological closing. if (edge.is_finite()) {
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL); auto v0 = edge.vertex0();
CHECK_FALSE(vd.is_valid()); auto v1 = edge.vertex1();
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(),
@@ -1941,7 +1935,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.
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd) [[maybe_unused]] 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; };
@@ -1963,7 +1957,7 @@ static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
// 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.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input. // Rotation of the polygon solves this problem.
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
{ {
Polygon poly = { Polygon poly = {
@@ -1987,14 +1981,7 @@ 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
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by // REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
// 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
@@ -2002,7 +1989,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.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input. // Rotation of the polygons solves this problem.
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]") TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
{ {
Polygons poly = { Polygons poly = {
@@ -2034,20 +2021,13 @@ 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
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by // REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// 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.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input. // Rotating the polygon also help solve this problem.
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]") TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
{ {
Polygons poly = { Polygons poly = {
@@ -2075,27 +2055,20 @@ 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);
VD vd; BoostVD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
vd.construct_voronoi(lines.begin(), lines.end()); construct_voronoi(lines.begin(), lines.end(), &vd);
#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
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by // REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// 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.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input. // Rotating of the input polygon will help solve this problem.
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2122,7 +2095,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
auto has_duplicate_vertices = [](const VD &vd) -> bool { [[maybe_unused]] 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()));
@@ -2131,21 +2104,14 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
return std::unique(vertices.begin(), vertices.end()) != vertices.end(); return std::unique(vertices.begin(), vertices.end()) != vertices.end();
}; };
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by // REQUIRE(!has_duplicate_vertices(vd));
// 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.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input. // Rotating of the input polygon will help solve this problem.
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2169,7 +2135,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
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool { [[maybe_unused]] 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()));
@@ -2200,12 +2166,5 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
return false; return false;
}; };
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by // REQUIRE(!has_intersecting_edges(poly, vd));
// 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));
} }
@@ -150,6 +150,13 @@ public:
return m_close_frames; return m_close_frames;
} }
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
bool wait_for_close_frames(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
}
// Hangs up every connection that is waiting. // Hangs up every connection that is waiting.
void release() void release()
{ {
@@ -277,6 +284,7 @@ private:
if (ec == websocket::error::closed) { if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames; ++m_close_frames;
m_changed.notify_all();
} }
if (ec) if (ec)
reading = false; reading = false;
@@ -449,6 +457,7 @@ TEST_CASE("Stopping the status stream sends the printer a close frame", "[Moonra
REQUIRE(f.printer.wait_for_ready_connections(1)); REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s)); CHECK(f.stops_within(3s));
CHECK(f.printer.wait_for_close_frames(1));
CHECK(f.printer.close_frames() == 1); CHECK(f.printer.close_frames() == 1);
} }
+112
View File
@@ -14,6 +14,7 @@
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <string> #include <string>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -325,6 +326,117 @@ TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "
} }
} }
TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]")
{
SECTION("a URL is reduced to its scheme and host")
{
CHECK(PluginAuditManager::normalize_network_url("https://API.Example.com:8443/v1/items?page=2#top") ==
"https://api.example.com");
CHECK(PluginAuditManager::normalize_network_url("http://example.com") == "http://example.com");
CHECK(PluginAuditManager::normalize_network_url("https://[2001:db8::1]:443/path") == "https://[2001:db8::1]");
}
SECTION("non-http schemes and targets without a scheme are not network URLs")
{
CHECK(PluginAuditManager::normalize_network_url("ftp://example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("api.example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("https://").empty());
}
SECTION("a URL target matches the same scheme and host regardless of path and port")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com/v1/items?page=2"));
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com:8443/upload"));
}
SECTION("a URL target does not match a different host or a different scheme")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://evil.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "http://api.example.com/v1"));
}
SECTION("a bare host target matches the host of a grant")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com"));
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "other.example.com"));
}
SECTION("legacy persisted address tuples keep matching their host")
{
const std::vector<std::string> legacy_granted{"('api.example.com', 443)"};
CHECK(PluginAuditManager::network_permission_matches(legacy_granted, "api.example.com"));
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "('api.example.com', 443)"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "('93.184.216.34', 443)"));
}
SECTION("an IPv6 literal matches across bracketed and bare forms")
{
const std::vector<std::string> granted{"https://[2001:db8::1]"};
CHECK(PluginAuditManager::network_permission_matches(granted, "2001:db8::1"));
CHECK(PluginAuditManager::network_permission_matches(granted, "[2001:db8::1]:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "2001:db8::2"));
}
SECTION("a non-http URL only matches an exact grant")
{
const std::vector<std::string> granted{"ftp://example.com/file"};
CHECK(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/file"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/other"));
const std::vector<std::string> https{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(https, "ftp://api.example.com/file"));
}
SECTION("an empty grant list or an empty target never matches")
{
const std::vector<std::string> granted{};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://api.example.com"));
const std::vector<std::string> any{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(any, ""));
}
}
TEST_CASE("Plugin audit limits network ancestor approvals to resolved addresses", "[audit]")
{
SECTION("IP literals are distinguished from hostnames and URLs")
{
CHECK(PluginAuditManager::is_ip_address_target("127.0.0.1"));
CHECK(PluginAuditManager::is_ip_address_target("93.184.216.34"));
CHECK(PluginAuditManager::is_ip_address_target("::1"));
CHECK(PluginAuditManager::is_ip_address_target("2001:db8::1"));
CHECK(PluginAuditManager::is_ip_address_target("[2001:db8::1]"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("api.example.com"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("localhost"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target("https://api.example.com"));
CHECK_FALSE(PluginAuditManager::is_ip_address_target(""));
}
SECTION("a network approval is kept apart from the general call-site cascade")
{
PluginAuditManager& mgr = PluginAuditManager::instance();
const std::string plugin_key = "plugin-audit-network-ancestor";
const std::string other_key = "plugin-audit-network-ancestor-other";
mgr.record_approved_network_call_sites(plugin_key, {"frame:network"});
mgr.record_approved_call_sites(plugin_key, {"frame:general"});
CHECK(mgr.has_approved_network_ancestor(plugin_key, {"frame:network"}));
CHECK_FALSE(mgr.has_approved_network_ancestor(plugin_key, {"frame:general"}));
CHECK_FALSE(mgr.has_approved_network_ancestor(other_key, {"frame:network"}));
// The network approval must not leak into the general cascade used by other categories.
CHECK_FALSE(mgr.has_approved_ancestor(plugin_key, {"frame:network"}));
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]") TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{ {
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly"); ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
@@ -8,7 +8,6 @@
#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>
@@ -18,12 +17,6 @@
#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>
@@ -112,72 +105,6 @@ 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]")
@@ -430,27 +357,26 @@ 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 PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY); const std::string defaults_body = " def get_name(self): return 'cap'\n"
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY); " def get_default_config(self): return {'speed': 1}\n";
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(host_config().store_capability_config(target, edited)); REQUIRE(py_save_config(target, edited));
REQUIRE(host_config().store_capability_config(bystander, edited)); REQUIRE(py_save_config(bystander, edited));
const json response = PluginConfig::restore_config_response(target); // What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
auto iface = as_interface(target);
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
CHECK(response["ok"] == true); CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
CHECK(response["config"] == json{{"speed", 1}}); CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
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]")
@@ -458,23 +384,24 @@ 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());
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY); py::object cap = make_capability("RaisingDefaultsCap",
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}})); " def get_name(self): return 'cap_a'\n"
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
const json response = PluginConfig::restore_config_response(raising); REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
CHECK(response["ok"] == false); auto iface = as_interface(cap);
CHECK_FALSE(response["error"].get<std::string>().empty()); REQUIRE(iface);
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}}); CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY); // The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
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.4.0"; descriptor.latest_version = "1.3.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.4.0"); CHECK(descriptor.latest_available_version() == "1.3.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,19 +615,12 @@ 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 from the region slices and refreshes the // lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
// 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);
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid")); // (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
REQUIRE(layer.lslices.empty());
ly.attr("make_slices")(); ly.attr("make_slices")();
REQUIRE(layer.lslices.size() == 1); CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
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]") {