Compare commits

..
Author SHA1 Message Date
Hanif Koh ec5580667d Address Review of the Reworked Unit Tests
Recompute the v4 recipe comparison once instead of per feature (148 s to 6 s) and compare bounding boxes as well as volumes; drop the incidental NaN loop from the unrepairable Voronoi case; prove the renamed preset is the one selected instead of checking a dirty flag that selecting clears; tell sparse from empty infill in the solid-shell test; plus smaller cleanups.
2026-10-19 18:12:40 +08:00
Hanif Koh 32d92a55d3 Make Unit Tests That Could Never Fail Check Real Behavior
Rework tests whose assertions held whatever the production code did: inverted or self-referential checks, checks on values the test had just set, branches that never ran, symmetric fixtures that hid the behavior under test, font paths that skipped silently, and Voronoi reproducers that only exercised boost. Each now asserts the behavior its name describes.
2026-10-19 17:02:49 +08:00
28 changed files with 632 additions and 1107 deletions

No files matched your search

@@ -1,39 +0,0 @@
# /// 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)
+3 -13
View File
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
MetadataList metadata;
VolumeMetadataList volumes;
std::optional<bool> auto_drop;
bool auto_drop { true };
};
struct CutObjectInfo
@@ -1208,7 +1208,6 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
//IdToGeometryMap m_orig_geometries; // backup & restore
CurrentConfig m_curr_config;
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;
IdToLayerHeightsProfileMap m_layer_heights_profiles;
IdToLayerConfigRangesMap m_layer_config_ranges;
@@ -1464,7 +1463,6 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_objects.clear();
m_instances.clear();
m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_curr_metadata_name.clear();
m_curr_characters.clear();
@@ -1504,7 +1502,6 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_config.object_id = -1;
m_curr_config.volume_id = -1;
m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_layer_heights_profiles.clear();
m_layer_config_ranges.clear();
m_brim_ear_points.clear();
@@ -2260,14 +2257,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
ObjectMetadata::VolumeMetadataList volumes;
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);
if (obj_metadata != m_objects_metadata.end()) {
// config data has been found, this model was saved using slic3r pe
model_object->auto_drop = obj_metadata->second.auto_drop.value_or(legacy_auto_drop);
model_object->auto_drop = obj_metadata->second.auto_drop;
// apply object's name and config data
for (const Metadata& metadata : obj_metadata->second.metadata) {
@@ -2285,7 +2279,6 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
else {
// 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
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
@@ -4128,8 +4121,6 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
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));
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);
}
@@ -4498,8 +4489,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
ObjectMetadata metadata;
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);
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
m_objects_metadata.insert({ object_id, std::move(metadata) });
m_curr_config.object_id = object_id;
return true;
+87 -461
View File
@@ -5,14 +5,10 @@
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
#include "libslic3r_version.h"
#include <boost/algorithm/string/case_conv.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/operations.hpp>
#include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <algorithm>
#include <cctype>
@@ -150,205 +146,6 @@ 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
// ---------------------------------------------------------------------------
@@ -674,39 +471,6 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
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,
const std::vector<std::string>& paths)
{
@@ -728,80 +492,60 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (missing.empty())
return true;
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)))
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
seed_install_metadata(state, descriptor);
auto show_dialog = [&descriptor, &missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
};
bool granted = false;
if (wxIsMainThread()) {
granted = show_dialog();
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
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)
{
m_last_violation = violation;
@@ -833,38 +577,13 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
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())
return false;
std::lock_guard<std::mutex> lock(m_mutex);
auto it = registry.find(plugin_key);
if (it == registry.end())
auto it = m_approved_call_sites.find(plugin_key);
if (it == m_approved_call_sites.end())
return false;
for (const auto& id : call_site_ids)
@@ -873,15 +592,14 @@ bool PluginAuditManager::has_call_site(const CallSiteRegistry& registry,
return false;
}
void PluginAuditManager::record_call_sites(CallSiteRegistry& registry,
const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = registry[plugin_key];
auto& approved = m_approved_call_sites[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end());
}
@@ -898,39 +616,6 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t 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)
{
if (!object)
@@ -942,12 +627,26 @@ std::string python_path(PyObject* object)
return {};
}
std::string result;
python_text(path_object, result);
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
std::string result = path ? path : "";
Py_DECREF(path_object);
if (!path)
PyErr_Clear();
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)
{
if (!object)
@@ -964,27 +663,6 @@ std::string python_str(PyObject* object)
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> ids;
@@ -1088,10 +766,8 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) {
const bool network_event = is_network_category(category);
for (const Py_ssize_t index : it->second) {
std::string value = network_event ? python_network_host(tuple_item(args, index))
: python_str(tuple_item(args, index));
std::string value = python_str(tuple_item(args, index));
if (!value.empty()) {
targets.push_back(std::move(value));
break;
@@ -1121,20 +797,6 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
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,
PluginInstallState& state,
std::vector<std::string>& permission_list,
@@ -1144,7 +806,15 @@ bool persist_permission(const std::string& plugin_key,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
seed_install_metadata(state, descriptor);
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
// needed by the writer so clicking Yes still creates the JSON file.
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
if (!has_permission(permission_list, target))
permission_list.push_back(target);
@@ -1223,10 +893,11 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
for (const auto& target : unresolved)
target_list += wxString::FromUTF8(target.c_str()) + "\n";
return show_permission_dialog(
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()),
target_list, unresolved.size()),
_L("Plugin permission request"));
wxMessageDialog dialog(nullptr,
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
@@ -1286,30 +957,7 @@ int decide_audited_event(PluginAuditManager& mgr,
const std::vector<std::string>& call_site_ids)
{
std::vector<std::string> unresolved = targets;
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) {
if (permission_list) {
unresolved.clear();
for (const auto& target : targets)
if (!has_permission(*permission_list, target))
@@ -1325,12 +973,6 @@ int decide_audited_event(PluginAuditManager& mgr,
for (const auto& target : unresolved)
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);
return 0;
}
@@ -1382,28 +1024,12 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urllib.request will call socket.connect.
// Collect the stdlib frames now so an approved network host can cover the resolved address
// of the same connection without covering another host.
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
// 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)) {
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
}
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
+2 -51
View File
@@ -138,33 +138,6 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key,
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);
bool audit_denial_pending() const;
void clear_audit_denial();
@@ -178,20 +151,9 @@ public:
// 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
// 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;
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;
private:
@@ -208,23 +170,12 @@ private:
static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation;
// mutable: the deny and ancestor queries are const but must lock.
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
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<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords;
CallSiteRegistry m_approved_call_sites;
CallSiteRegistry m_approved_network_call_sites;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
};
// RAII guard that sets the current plugin key and capability name, restoring the previous
+15 -27
View File
@@ -832,29 +832,6 @@ 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)
{
namespace fs = boost::filesystem;
@@ -897,9 +874,16 @@ 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
// during register_capabilities() or by a previous runtime audit prompt.
read_install_state(plugin_dir, state);
apply_install_metadata(state, entry);
state.enabled = enabled;
state.capabilities = capabilities;
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
// 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);
}
@@ -910,7 +894,11 @@ 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
// while preserving permissions once the package is loaded, via the overload above.
PluginInstallState state;
seed_install_metadata(state, 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();
state.enabled = true;
return write_install_state(plugin_dir, state);
}
-7
View File
@@ -184,19 +184,12 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
// 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);
// 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,
const std::vector<std::pair<std::string, bool>>& capabilities);
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
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
// sidecar was present and valid.
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
+6 -2
View File
@@ -1180,8 +1180,12 @@ void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin
const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state;
if (!read_install_state(root, state))
seed_install_metadata(state, descriptor);
if (!read_install_state(root, state)) {
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 = false;
if (!write_install_state(root, state))
return;
+9 -66
View File
@@ -50,7 +50,6 @@ std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
struct PluginInstanceHandle
{
// The C++ plugin interface points into a Python object. Keep both alive through one
@@ -90,7 +89,6 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
}
g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
}
} // namespace
@@ -116,7 +114,6 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key);
g_pending_package.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
// Python code on this thread are attributed to this plugin.
@@ -148,7 +145,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key);
g_pending_network_permissions.erase(capture_key);
};
try {
@@ -204,30 +200,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
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) {
log_python_exception_keep(err);
error = err.what();
@@ -351,7 +323,6 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key);
g_pending_package.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)
@@ -379,7 +350,6 @@ void PythonPluginBridge::clear_pending_captures()
}
g_pending_package.clear();
g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear();
return;
}
@@ -389,7 +359,6 @@ void PythonPluginBridge::clear_pending_captures()
g_pending_capabilities.clear();
g_pending_package.clear();
g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear();
}
@@ -589,50 +558,24 @@ void bind_python_api(pybind11::module_& m)
m.def(
"request_permissions",
[](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) {
[](const std::vector<std::string>& fs_read) {
if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context");
// Validate and canonicalize everything before touching the pending registry, so a
// 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);
}
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
}
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);
}
if (std::find(requested.begin(), requested.end(), path) == requested.end())
requested.push_back(path);
}
},
py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
R"pbdoc(Request permissions while OrcaSlicer loads your module.
py::arg("fs_read") = std::vector<std::string>{},
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
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");
The host presents the request to the user after register_capabilities() returns. Denying the
request aborts the plugin load.)pbdoc");
m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty())
+39 -5
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "test_helpers.hpp"
#include <algorithm>
@@ -17,15 +18,48 @@
using namespace Slic3r;
using namespace Slic3r::Test;
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
// fan-off command is emitted.
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
{
const int fan_off_layers = 5;
const std::string gcode = slice({ cube(20) }, {
{ "cooling", true },
{ "close_fan_the_first_x_layers", 5 },
{ "close_fan_the_first_x_layers", fan_off_layers },
{ "full_fan_speed_layer", 0 },
{ "fan_min_speed", 50 },
{ "fan_max_speed", 100 },
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
});
CHECK(gcode.find("M106 S0") != std::string::npos);
// Record, for each layer, whether any extrusion was printed with the part fan running.
std::vector<bool> extruded_with_fan;
int layer = -1;
bool fan_on = false;
std::istringstream input(gcode);
std::string line;
while (std::getline(input, line)) {
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
++layer;
extruded_with_fan.push_back(false);
} else if (line.rfind("M107", 0) == 0) {
fan_on = false;
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
const size_t s = line.find(" S");
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
extruded_with_fan[layer] = true;
}
}
// A 20 mm cube at 0.2 mm layers has 100 layers.
REQUIRE(extruded_with_fan.size() == 100);
for (int i = 0; i < fan_off_layers; ++i) {
CAPTURE(i);
CHECK_FALSE(extruded_with_fan[i]);
}
CHECK(extruded_with_fan[fan_off_layers]);
}
// The cooling pass resolves and strips its internal speed placeholders; none leak into
+9 -10
View File
@@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
// 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
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
// and a single line crosses the middle, ending within half a spacing of the edges.
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
test_set.clear();
}
@@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
// 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
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
}
SECTION("Rotated Square") {
@@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
fill_params.density = 1.0;
filler->spacing = flow.spacing();
for (auto angle : { 0.0, 45.0}) {
for (auto angle : { 0.0, PI / 4.0 }) {
surface.expolygon.rotate(angle, Point(0,0));
filler->bounding_box = get_extents(surface.expolygon.contour);
Polylines paths = filler->fill_surface(&surface, fill_params);
REQUIRE(paths.size() == 1);
}
@@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
};
Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
}
}
+42 -18
View File
@@ -123,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
config.set_deserialize_strict({
{ "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
// Shell counts alone decide which layers are solid.
{ "top_shell_thickness", 0 },
{ "bottom_shell_thickness", 0 },
{ "sparse_infill_density", "20%" },
{ "layer_height", 0.25 }, // 80 layers: 0..79
{ "initial_layer_print_height", 0.25 }
});
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
// and one solid bottom layer (0).
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
// True when the layer has fill surfaces and all of them are solid.
auto 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));
// iterate over all of the regions in the layer
for (const LayerRegion *region : layer.regions()) {
// for each region, iterate over the fill surfaces
bool has_fill = false;
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces) {
if (! surface.is_solid())
return false;
has_fill = true;
}
return has_fill;
};
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces)
CHECK(surface.is_solid());
}
if (! surface.is_solid())
return true;
return false;
};
print.process();
test_is_solid_infill(0, 0); // should be solid
test_is_solid_infill(0, 79); // should be solid
test_is_solid_infill(0, 78); // should be solid
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
REQUIRE(is_solid_infill(0, 0));
REQUIRE(is_solid_infill(0, 79));
REQUIRE(is_solid_infill(0, 78));
REQUIRE(has_sparse_infill(0, 77));
WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3);
print.apply(model, config);
print.process();
THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0);
CHECK(is_solid_infill(0, 0));
}
AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79);
test_is_solid_infill(0, 78);
test_is_solid_infill(0, 77);
CHECK(is_solid_infill(0, 79));
CHECK(is_solid_infill(0, 78));
CHECK(is_solid_infill(0, 77));
CHECK(has_sparse_infill(0, 76));
}
AND_THEN("Middle layers keep sparse infill") {
CHECK(has_sparse_infill(0, 40));
}
}
}
@@ -672,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; 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
+20 -10
View File
@@ -1,6 +1,8 @@
#include <catch2/catch_all.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/TriangleMeshSlicer.hpp"
#include "libslic3r/Point.hpp"
@@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
auto cube = make_cube();
TriangleMesh mesh(vertices, facets);
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
const double wall_area = 50. * 5.;
const double wall_block_area = wall_area + 20. * 15.;
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
WHEN(" a top tangent plane is sliced") {
// At Z = 10 we have a top horizontal surface.
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
THEN( "its area is included") {
REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).at(0).area() > 0);
REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
}
}
WHEN(" a model that has been transformed is sliced") {
cube.mirror_z();
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
THEN( "it is sliced properly (mirrored bottom plane area is included)") {
REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).at(0).area() > 0);
mesh.mirror_z();
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
}
}
}
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
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);
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 (!result.constraints_ok) {
for (auto& v : result.violations)
+41 -40
View File
@@ -505,28 +505,6 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
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]")
{
// The importer queues at most 100000 component references per object. Every reference besides the
@@ -1055,18 +1033,19 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
}
}
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
// importer and must not fabricate a filament_volume_map.
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
GIVEN("a project without any multi-nozzle metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
// A project without multi-nozzle metadata must load through the BBS importer and must not
// fabricate a filament_volume_map.
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
GIVEN("a legacy project with a bare model and no plate metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
bool loaded = false;
@@ -1074,10 +1053,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]
&project_presets, &is_bbl_3mf, &is_orca_3mf,
&file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("it does not crash and invents no per-filament volume map") {
for (PlateData* p : plates) {
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
}
THEN("the model loads") {
REQUIRE(loaded);
REQUIRE_FALSE(model.objects.empty());
}
release_PlateData_list(plates);
}
}
GIVEN("a project with plate metadata but no filament volume map") {
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
Model model;
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("every plate loads without a per-filament volume map") {
REQUIRE(loaded);
REQUIRE_FALSE(plates.empty());
for (PlateData* p : plates)
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
}
release_PlateData_list(plates);
}
@@ -2048,8 +2049,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
// The loaded 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]);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true);
+118 -93
View File
@@ -44,6 +44,7 @@
#include <cmath>
#include <cstring>
#include <boost/filesystem/path.hpp>
#include <fstream>
#include <memory>
#include <set>
@@ -53,6 +54,7 @@
#include <BRepAdaptor_Surface.hxx>
#include <TopExp_Explorer.hxx>
#include <TopoDS.hxx>
#include <BRepAlgoAPI_Common.hxx>
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <BRepPrimAPI_MakeBox.hxx>
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
{
std::string font = resources_dir().empty()
? 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;
}
}
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
const std::string font =
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
REQUIRE(std::ifstream(font).good());
const std::string charset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -2853,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Build a body so we have a face to reference for FaceAndDirection.
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
// direction, so the frame has to fall back on coordsys_x_hint.
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
@@ -2865,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
if (fn.z() > 0.9) { top_face = i; break; }
}
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");
REQUIRE(cs >= 0);
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
doc.features[cs].coordsys_body = 0;
doc.features[cs].coordsys_face = top_face;
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
// Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
auto css = doc.resolve_datum_coordsys();
REQUIRE(css.size() == 1);
REQUIRE(css[0].error.empty());
Vec3d X = css[0].x, Y = css[0].y;
// Orthonormality: each axis has unit length
const Vec3d X = css[0].x, Y = css[0].y;
// X is the hint projected into the face plane.
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
// Orthonormal, with Z the face normal and the frame right-handed.
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
// Pairwise dot products are ~0
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
// Z = X x Y (derived), also unit and perpendicular
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));
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
}
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
@@ -3917,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
}
REQUIRE(top_face >= 0);
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 appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
REQUIRE(appended == 4);
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == body_count_before);
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
SECTION("construction edges form no profile, so the extrude fails") {
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
}
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]")
@@ -4252,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
REQUIRE(doc.features.empty()); // nothing half-read was left behind
}
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
{
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
@@ -4282,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
}
CadDocument expected = make_golden_doc_v1();
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
// scope for the field-value assertions below, where "regenerate the fixture" is the
// one thing you must never do -- regenerating after a reorder bakes the corrupted
// layout in as the new golden and permanently disarms this test.
{
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
"frozen in cad_recipe_v4.bin, so do not add or remove features there.");
REQUIRE(features.size() == expected.features.size());
}
// 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
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
// fix the field order instead.
// Field-by-field assertions against expected values.
// Every field set to a distinctive non-default literal must be checked here.
@@ -4550,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
}
}
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
// Build expected document and try to recompute it.
// ponytail: the field-value layer above is the real tripwire;
// this layer is a bonus sanity check on the full recompute path.
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
// The golden tree is serialization coverage and does not recompute as a whole: these
// features fail on the geometry they get, so they are left off in both trees.
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
"GoldenProject"};
CadDocument exp_doc = make_golden_doc_v1();
bool exp_ok = exp_doc.recompute();
if (!exp_ok) {
INFO("Expected document from make_golden_doc_v1() could not recompute "
"(complex feature tree). Field-value checks above are sufficient.");
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults
// (a v4 thread, for one, is not major-nominal). The reference has to match that.
const CadFeature def;
for (CadFeature& f : exp_doc.features) {
f.thread_major_nominal = def.thread_major_nominal;
f.pattern_inclusive = def.pattern_inclusive;
f.dressup_edges = def.dressup_edges;
f.text_string = def.text_string;
f.text_font = def.text_font;
f.text_height = def.text_height;
f.revolve_axis_entity = def.revolve_axis_entity;
}
CadDocument doc;
bool ser_ok = doc.deserialize_recipe(blob);
if (exp_ok && ser_ok) {
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t i = 0; i < doc.bodies.size(); ++i) {
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
doc.deserialize_recipe(blob);
REQUIRE(doc.features.size() == exp_doc.features.size());
for (size_t i = 0; i < exp_doc.features.size(); ++i)
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
const bool exp_built = exp_doc.recompute();
INFO("reference error: " << exp_doc.error);
REQUIRE(exp_built);
const bool built = doc.recompute();
INFO("loaded error: " << doc.error);
REQUIRE(built);
REQUIRE_FALSE(exp_doc.bodies.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t b = 0; b < doc.bodies.size(); ++b) {
INFO("body " << b);
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
// Volume alone is blind to fields that only place a body, such as a transform.
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
for (int k = 0; k < 3; ++k) {
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
}
} else {
INFO("The golden recipe fixture was loaded and field-value checks passed.");
INFO("Recompute on the deserialized or expected document failed — this is");
INFO("expected for the extended golden fixture (many feature types coexist");
INFO("purely for serialization coverage). The field-value tripwire above is");
INFO("the primary format check.");
}
}
@@ -5781,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
@@ -5801,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), 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]")
@@ -5823,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
// The connector sits on the cylinder's base, so the flip decides which side of the
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
@@ -5837,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
// but the mate executed cleanly and the body moved to the target connector.
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
}
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
@@ -6525,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Fastened, NO flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps nf_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
double nf_z = nf_props.CentreOfMass().Z();
doc.undo();
// Fastened, WITH flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
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();
// The same assembly mated once without and once with flip, each in its own copy.
auto mated_com_z = [&](bool flip) {
CadDocument d = doc;
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
REQUIRE(d.recompute());
REQUIRE(d.error.empty());
REQUIRE(d.mate_conflicts.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(d.bodies[1].shape, props);
return props.CentreOfMass().Z();
};
const double nf_z = mated_com_z(false);
const double f_z = mated_com_z(true);
// Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
@@ -7324,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
CadDocument doc;
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");
// A sheet passing straight through the solid: it has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
doc.add_surface_extrude(sk_s, 5.0, "SE");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 2);
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());
}
@@ -7540,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
CHECK(doc.error.find("removed no material") != std::string::npos);
// And the body is left as it was, not half-applied.
CadDocument again = box();
REQUIRE(again.recompute());
CHECK_THAT(again.body_mass_properties(0).volume,
REQUIRE(doc.bodies.size() == 1);
CHECK_THAT(doc.body_mass_properties(0).volume,
Catch::Matchers::WithinAbs(solid, 1e-6));
}
+7 -10
View File
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
THEN("A BadOptionTypeException exception is thrown.") {
config.set("gcode_comments", true);
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
}
AND_THEN("Value is unchanged.") {
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
}
}
WHEN("A boolean option is set to an int value") {
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("A numeric option is set to a non-numeric value.") {
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
THEN("A BadOptionTypeException exception is thrown.") {
config.set("max_bridge_length", 7.5);
THEN("A BadOptionValueException exception is thrown and the value does not change.") {
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
}
THEN("The value does not change.") {
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
}
}
WHEN("A string option is set through the string interface") {
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("An invalid option is requested during set.") {
THEN("A BadOptionTypeException exception is thrown.") {
THEN("A UnknownOptionException exception is thrown.") {
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
+6 -22
View File
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
}
}
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
{
// Regression for the "extra purge tower" on a belt printer with a mixed
// filament (MCTEST5). The belt purge prism is sized as
// 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.
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
// components, so it must not count as a filament of its own.
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
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 auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
// Slot 4 dissolves into 1 and 3, which are already present.
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") {
// Only the mixed slot is assigned: it must still yield its two components,
// i.e. one island, rather than collapsing to zero.
// Only the mixed slot is assigned: it must still yield its two components rather than
// collapsing to nothing.
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
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") {
+16 -15
View File
@@ -1334,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
// Normalizing rewrites config in place without flagging the preset dirty.
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
bundle.prints.select_preset_by_name("My Process", true);
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// A system preset that already references the current name is left untouched (idempotent no-op).
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the
// edited copy, so the selection is not reported modified.
bundle.normalize_compatible_presets();
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]")
@@ -1369,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
std::vector<std::string>{ "New Process" });
// The rewrite does not flag the system preset dirty, and is idempotent.
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
bundle.filaments.select_preset_by_name("System Filament", true);
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// Normalizing again is a no-op, so the selection is not reported modified.
bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
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]")
@@ -5566,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_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
+5 -13
View File
@@ -5,9 +5,9 @@
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/CAD/SketchImport.hpp"
#include "libslic3r/Utils.hpp" // resources_dir
#include "test_utils.hpp" // ScopedTemporaryFile
#include <boost/filesystem/path.hpp>
#include <cstdlib>
#include <fstream>
#include "libslic3r/Point.hpp"
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
{
// Locate the bundled font; resources_dir() may be unset under ctest, so
// fall back to a cwd-relative path (tests run from the repo root).
std::string font = resources_dir().empty()
? 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;
}
}
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
const std::string font =
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
REQUIRE(std::ifstream(font).good());
// Bad input is rejected without throwing.
REQUIRE(text_to_regions("", 10.0, font).empty());
+3 -2
View File
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
// Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
// wins even though the midpoint is nearer.
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
CHECK(s.kind == K::Endpoint);
CHECK(s.role == SketchPointRole::P1);
}
+4 -7
View File
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
REQUIRE(closed_wires(out) == 1);
}
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
{
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
// 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);
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
// closed wire, not four disconnected segments. Its area is checked by the sign test below.
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
REQUIRE(ws.size() == 1);
REQUIRE(ws[0].Closed());
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
}
} // namespace
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
{
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
auto has_multiple_nozzle = [](const std::vector<int> &values) {
return std::any_of(values.begin(), values.end(), [](int v) { return v > 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}));
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
// (any extruder_max_nozzle_count > 1) stays closed.
REQUIRE_FALSE(opt->values.empty());
for (int count : opt->values)
CHECK(count == 1);
}
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
+75 -34
View File
@@ -32,6 +32,7 @@
using namespace Slic3r;
using VD = Geometry::VoronoiDiagram;
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
using BoostVD = boost::polygon::voronoi_diagram<double>;
// https://svn.boost.org/trac10/ticket/12067
@@ -198,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
REQUIRE(intersecting_edges({ poly }).empty());
Lines lines = to_lines(poly);
BoostVD vd;
construct_voronoi(lines.begin(), lines.end(), &vd);
VD vd;
vd.construct_voronoi(lines.begin(), lines.end());
// Orca's detector finds nothing wrong with this input, so no repair is needed.
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
@@ -305,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
}
#endif
BoostVD vd;
VD vd;
Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd);
vd.construct_voronoi(lines.begin(), lines.end());
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
// by rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
@@ -347,9 +359,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
// https://svn.boost.org/trac10/ticket/12139
// Funny sample from a dental industry?
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
// This test is suppressed.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
// The input data contain self intersections, so no valid Voronoi diagram can be built from it.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
{
Lines lines = {
{ { 260500,1564400 }, { 261040,1562960 } },
@@ -1327,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
}
#endif
BoostVD vd;
construct_voronoi(lines.begin(), lines.end(), &vd);
for (const auto& edge : vd.edges())
if (edge.is_finite()) {
auto v0 = edge.vertex0();
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())));
}
VD vd;
vd.construct_voronoi(lines.begin(), lines.end());
// 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
// checks before falling back to a morphological closing.
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
CHECK_FALSE(vd.is_valid());
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
@@ -1935,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
}
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
{
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
@@ -1957,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
// 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.
// Rotation of the polygon solves this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
{
Polygon poly = {
@@ -1981,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
}
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
@@ -1989,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
// input polygon.
// Rotation of the polygons solves this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
{
Polygons poly = {
@@ -2021,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
}
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
// 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.
// Rotating the polygon also help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
{
Polygons poly = {
@@ -2055,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
// polygons_rotate(poly, PI/180);
// polygons_rotate(poly, PI/6);
BoostVD vd;
VD vd;
Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd);
vd.construct_voronoi(lines.begin(), lines.end());
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
}
// In this case, the Voronoi vertex (146873, -146873) is included twice.
// 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 of the input polygon will help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
{
Polygon poly = {
@@ -2095,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
#endif
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool {
auto has_duplicate_vertices = [](const VD &vd) -> bool {
std::vector<Vec2d> vertices;
for (const auto &vertex : vd.vertices())
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
@@ -2104,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
};
// REQUIRE(!has_duplicate_vertices(vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_duplicate_vertices(raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_duplicate_vertices(vd));
}
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
// 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.
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
// Rotating of the input polygon will help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
{
Polygon poly = {
@@ -2135,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
#endif
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
BoundingBox bbox = get_extents(poly);
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
@@ -2166,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
return false;
};
// REQUIRE(!has_intersecting_edges(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_intersecting_edges(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_intersecting_edges(poly, vd));
}
@@ -150,13 +150,6 @@ public:
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.
void release()
{
@@ -284,7 +277,6 @@ private:
if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames;
m_changed.notify_all();
}
if (ec)
reading = false;
@@ -457,7 +449,6 @@ TEST_CASE("Stopping the status stream sends the printer a close frame", "[Moonra
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s));
CHECK(f.printer.wait_for_close_frames(1));
CHECK(f.printer.close_frames() == 1);
}
-112
View File
@@ -14,7 +14,6 @@
#include <boost/filesystem.hpp>
#include <string>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -326,117 +325,6 @@ 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]")
{
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
@@ -8,6 +8,7 @@
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp"
#include <pybind11/pytypes.h>
#include <pybind11/eval.h>
@@ -17,6 +18,12 @@
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <chrono>
#include <fstream>
#include <ios>
#include <memory>
#include <string>
#include <pybind11/cast.h>
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
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
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized)
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");
host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
const std::string defaults_body = " def get_name(self): return 'cap'\n"
" 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 PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
const json edited = json{{"speed", 99}};
REQUIRE(py_save_config(target, edited));
REQUIRE(py_save_config(bystander, edited));
REQUIRE(host_config().store_capability_config(target, edited));
REQUIRE(host_config().store_capability_config(bystander, edited));
// 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()));
const json response = PluginConfig::restore_config_response(target);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
CHECK(response["ok"] == true);
CHECK(response["config"] == json{{"speed", 1}});
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
CHECK(host_config().get_config(bystander)->config == edited);
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
}
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized)
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");
host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
py::object cap = make_capability("RaisingDefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
const json response = PluginConfig::restore_config_response(raising);
auto iface = as_interface(cap);
REQUIRE(iface);
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
CHECK(response["ok"] == false);
CHECK_FALSE(response["error"].get<std::string>().empty());
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
}
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;
descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0";
descriptor.latest_version = "1.4.0";
PluginChangelog changelog;
changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0");
CHECK(descriptor.latest_available_version() == "1.4.0");
}
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
// lslices are derived: make_slices() re-derives them from the region slices and refreshes the
// bbox cache.
TestLayer layer;
layer.add_region(nullptr);
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(layer.lslices.empty());
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
REQUIRE(layer.lslices.size() == 1);
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
REQUIRE(layer.lslices_bboxes.size() == 1);
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {