Compare commits

..
Author SHA1 Message Date
Hanif Koh f2c856a37e Open Project Attachments Through One Guarded Helper
The Edit Project Info view launched attachments directly, without the
checks the project page has. Both now call
desktop_open_project_attachment, which checks that the file is inside
the auxiliary directory, asks for confirmation where needed and then
opens it.

The auxiliary root was built through encode_path, which returns code
page bytes on Windows, while boost::filesystem reads a narrow string as
UTF-8. With a non-ASCII temporary directory the root never matched and
no attachment opened. It is now built from the UTF-8 path directly.

The list of program extensions could not be kept complete and let
unknown types open without a prompt. It is replaced by
is_safe_to_open_file_name, a list of plain document, image, model and
video types that open directly. Everything else asks first.
2026-09-29 23:50:10 +08:00
Hanif Koh 40028f7b61 Confirm Before Opening Program Attachments and Load Only HTTPS Images
Opening a project attachment whose type runs as a program or script
(executables, installers, shortcuts, shell and PowerShell scripts, macOS
command files and apps, Linux desktop entries) now asks for confirmation
first. The check lives in libslic3r as is_executable_file_name and ignores
the trailing dots and spaces Windows strips from file names.

Images in project descriptions are kept only when they load over https,
so opening the Project tab no longer issues plain-http requests.
2026-09-29 23:50:10 +08:00
Hanif Koh 88f5905c29 Tighten Project Page Description Rendering and Keep More Formatting
Link and image URLs in descriptions must now start with an http or https
scheme as written and parse as such with the URL parser. Preview images are
built as DOM nodes like the file list, and accessory names show their full
text as a tooltip.

Descriptions keep more plain formatting: del, ins, figure, figcaption, dl,
dt, dd, caption, q, abbr, kbd and wbr, plus alt, title, width and height on
images, colspan and rowspan on table cells and start on ordered lists.
Numeric attributes must be plain integers. Embedded YouTube players become
a link to the video.
2026-09-29 23:50:10 +08:00
Hanif Koh f8e531f07b Escape Project Metadata in the Project Page and Restrict Accessory Opening
The Project page rendered the model and profile name, author, description
and accessory file names from the 3MF as live HTML. Names, authors and file
names are now set as text, and the file list is built from DOM nodes with
bound click handlers instead of concatenated markup. Descriptions can
legitimately carry rich-text HTML, so they are rebuilt from an inert
DOMParser document, keeping only plain formatting tags, http(s) links and
http(s) images, with every other attribute dropped.

Opening an accessory from the page now only launches regular files that
lie inside the project's extracted auxiliary directory. The containment
check is a new libslic3r helper, is_absolute_path_within_root, built on
is_path_within_root so symlinks leading out of the root are rejected too.
2026-09-29 23:50:10 +08:00
18 changed files with 86 additions and 331 deletions
+8 -12
View File
@@ -141,7 +141,7 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
its.indices.emplace_back(indices[0], indices[1], indices[2]); its.indices.emplace_back(indices[0], indices[1], indices[2]);
int face_index =its.indices.size() - 1; int face_index =its.indices.size() - 1;
RGBA face_color; RGBA face_color;
auto set_face_color = [&uvs, &data, &mtl_data, &obj_info, &face_color](int face_index, const std::string mtl_name, const std::array<int, 3> &corners) { auto set_face_color = [&uvs, &data, &mtl_data, &obj_info, &face_color](int face_index, const std::string mtl_name) {
if (mtl_data.new_mtl_unmap.find(mtl_name) != mtl_data.new_mtl_unmap.end()) { if (mtl_data.new_mtl_unmap.find(mtl_name) != mtl_data.new_mtl_unmap.end()) {
bool is_merge_ka_kd = true; bool is_merge_ka_kd = true;
for (size_t n = 0; n < 3; n++) { for (size_t n = 0; n < 3; n++) {
@@ -174,7 +174,7 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
return Vec2f::Zero(); return Vec2f::Zero();
return Vec2f(data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH], data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH + 1]); return Vec2f(data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH], data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH + 1]);
}; };
std::array<Vec2f, 3> uv_array{uv_at(uvs[corners[0]]), uv_at(uvs[corners[1]]), uv_at(uvs[corners[2]])}; std::array<Vec2f, 3> uv_array{uv_at(uvs[0]), uv_at(uvs[1]), uv_at(uvs[2])};
obj_info.uvs.emplace_back(uv_array); obj_info.uvs.emplace_back(uv_array);
} }
obj_info.face_colors.emplace_back(face_color); obj_info.face_colors.emplace_back(face_color);
@@ -185,27 +185,27 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
} }
} }
}; };
auto set_face_color_by_mtl = [&data, &set_face_color](int face_index, const std::array<int, 3> &corners) { auto set_face_color_by_mtl = [&data, &set_face_color](int face_index) {
if (data.usemtls.size() == 1) { if (data.usemtls.size() == 1) {
set_face_color(face_index, data.usemtls[0].name, corners); set_face_color(face_index, data.usemtls[0].name);
} else { } else {
for (size_t k = 0; k < data.usemtls.size(); k++) { for (size_t k = 0; k < data.usemtls.size(); k++) {
auto mtl = data.usemtls[k]; auto mtl = data.usemtls[k];
if (face_index >= mtl.face_start && face_index <= mtl.face_end) { if (face_index >= mtl.face_start && face_index <= mtl.face_end) {
set_face_color(face_index, data.usemtls[k].name, corners); set_face_color(face_index, data.usemtls[k].name);
break; break;
} }
} }
} }
}; };
if (exist_mtl) { if (exist_mtl) {
set_face_color_by_mtl(face_index, {0, 1, 2}); set_face_color_by_mtl(face_index);
} }
if (cnt == 4) { if (cnt == 4) {
its.indices.emplace_back(indices[0], indices[2], indices[3]); its.indices.emplace_back(indices[0], indices[2], indices[3]);
int face_index = its.indices.size() - 1; int face_index = its.indices.size() - 1;
if (exist_mtl) { if (exist_mtl) {
set_face_color_by_mtl(face_index, {0, 2, 3}); set_face_color_by_mtl(face_index);
} }
} }
} }
@@ -217,12 +217,8 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
message = _L("This OBJ file couldn't be read because it's empty."); message = _L("This OBJ file couldn't be read because it's empty.");
return false; return false;
} }
if (meshptr->volume() < 0) { if (meshptr->volume() < 0)
meshptr->flip_triangles(); meshptr->flip_triangles();
// Flipping swaps corners 1 and 2 of every face, so the UVs have to follow.
for (std::array<Vec2f, 3> &uv : obj_info.uvs)
std::swap(uv[1], uv[2]);
}
// Hand the parsed material table back so callers can build a TexturedMesh from it. // Hand the parsed material table back so callers can build a TexturedMesh from it.
if (out_mtl) if (out_mtl)
*out_mtl = mtl_data; *out_mtl = mtl_data;
+3 -4
View File
@@ -31,15 +31,14 @@ int UserManager::parse_json(std::string payload)
{ {
bool restored_json = false; bool restored_json = false;
json j; json j;
//bind/unbind
try {
json j_pre = json::parse(payload); json j_pre = json::parse(payload);
if (j_pre.empty()) { if (j_pre.empty()) {
return -1; return -1;
} }
//bind/unbind
try {
if (j_pre.contains("bind")) { if (j_pre.contains("bind")) {
if (j_pre["bind"].contains("command")) { if (j_pre["bind"].contains("command")) {
+9
View File
@@ -274,4 +274,13 @@ bool Duet::start_print(wxString &msg, const std::string &filename, ConnectionTyp
return res; return res;
} }
int Duet::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} }
+1
View File
@@ -40,6 +40,7 @@ private:
ConnectionType connect(wxString &msg) const; ConnectionType connect(wxString &msg) const;
void disconnect(ConnectionType connectionType) const; void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const;
int get_err_code_from_body(const std::string &body) const;
}; };
} }
+9
View File
@@ -146,6 +146,15 @@ bool ESP3D::start_print(wxString& msg, const std::string& filename) const
return ret; return ret;
} }
int ESP3D::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
// ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour // ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour
std::string ESP3D::get_short_name(const std::string& filename) const std::string ESP3D::get_short_name(const std::string& filename) const
{ {
+1
View File
@@ -33,6 +33,7 @@ private:
std::string m_console_port; std::string m_console_port;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
std::string get_short_name(const std::string& filename) const; std::string get_short_name(const std::string& filename) const;
std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const; std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const;
}; };
+15 -25
View File
@@ -510,22 +510,12 @@ bool Flashforge::fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots
if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg)) if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg))
return false; return false;
if (!parse_material_slots(body, slots, supports_material_station)) { const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded()) {
msg = _(L("Flashforge returned an invalid JSON response.")); msg = _(L("Flashforge returned an invalid JSON response."));
return false; return false;
} }
return true;
}
bool Flashforge::parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station)
{
slots.clear();
const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded())
return false;
const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed; const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed;
const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] : const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] :
detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json(); detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json();
@@ -552,21 +542,12 @@ bool Flashforge::parse_material_slots(const std::string& body, std::vector<Flash
if (supports_material_station != nullptr) if (supports_material_station != nullptr)
*supports_material_station = reports_material_station; *supports_material_station = reports_material_station;
// Fields are read leniently: firmware may send numbers as strings or flags as numbers.
for (const auto& slot : slot_infos) { for (const auto& slot : slot_infos) {
if (!slot.is_object())
continue;
FlashforgeMaterialSlot info; FlashforgeMaterialSlot info;
info.slot_id = static_cast<int>(slots.size()) + 1; info.slot_id = slot.value("slotId", static_cast<int>(slots.size()) + 1);
if (const auto it = slot.find("slotId"); it != slot.end()) info.has_filament = slot.value("hasFilament", false);
try_parse_json_int(*it, info.slot_id); info.material_name = slot.value("materialName", std::string());
int has_filament = 0; info.material_color = slot.value("materialColor", std::string());
if (const auto it = slot.find("hasFilament"); it != slot.end() && try_parse_json_int(*it, has_filament))
info.has_filament = has_filament != 0;
if (const auto it = slot.find("materialName"); it != slot.end() && it->is_string())
info.material_name = it->get<std::string>();
if (const auto it = slot.find("materialColor"); it != slot.end() && it->is_string())
info.material_color = it->get<std::string>();
slots.emplace_back(std::move(info)); slots.emplace_back(std::move(info));
} }
@@ -689,4 +670,13 @@ std::string Flashforge::extract_host_name() const
return out; return out;
} }
int Flashforge::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // namespace Slic3r } // namespace Slic3r
+1 -2
View File
@@ -45,8 +45,6 @@ public:
PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; } PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; }
std::string get_host() const override { return m_host; } std::string get_host() const override { return m_host; }
bool fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const; bool fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const;
// Parses a local API "detail" reply. Returns false when the body is not valid JSON.
static bool parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station);
static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3); static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3);
private: private:
@@ -70,6 +68,7 @@ private:
bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const; bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const;
std::string make_http_url(const std::string& path) const; std::string make_http_url(const std::string& path) const;
std::string extract_host_name() const; std::string extract_host_name() const;
int get_err_code_from_body(const std::string &body) const;
bool connect(wxString& msg) const; bool connect(wxString& msg) const;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
}; };
+9
View File
@@ -141,4 +141,13 @@ bool MKS::start_print(wxString& msg, const std::string& filename) const
return ret; return ret;
} }
int MKS::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // Slic3r } // Slic3r
+1
View File
@@ -34,6 +34,7 @@ private:
std::string get_upload_url(const std::string& filename) const; std::string get_upload_url(const std::string& filename) const;
bool start_print(wxString& msg, const std::string& filename) const; bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
}; };
} }
+9 -42
View File
@@ -3,13 +3,10 @@
#include <vector> #include <vector>
#include <thread> #include <thread>
#include <exception> #include <exception>
#include <sstream>
#include <boost/optional.hpp> #include <boost/optional.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <wx/string.h> #include <wx/string.h>
#include <wx/app.h> #include <wx/app.h>
@@ -175,20 +172,6 @@ std::string moonraker_error_reason(const std::string &body)
} // namespace } // namespace
int PrintHost::get_err_code_from_body(const std::string &body)
{
boost::property_tree::ptree root;
std::istringstream iss(body);
try {
boost::property_tree::read_json(iss, root);
} catch (const std::exception &ex) {
BOOST_LOG_TRIVIAL(error) << "PrintHost: response is not valid JSON: " << ex.what();
return -1;
}
return root.get<int>("err", 0);
}
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{ {
if (status != 0) { if (status != 0) {
@@ -432,10 +415,12 @@ void PrintHostJobQueue::priv::remove_source()
source_to_remove.clear(); source_to_remove.clear();
} }
bool PrintHostJobQueue::upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn) void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job)
{ {
emit_progress(0); // Indicate the upload is starting
// Captured before upload_data is moved into upload() below. // Captured before upload_data is moved into upload() below.
const std::string upload_filename = job.upload_data.source_path.filename().string(); const std::string upload_filename = the_job.upload_data.source_path.filename().string();
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
@@ -444,37 +429,19 @@ bool PrintHostJobQueue::upload_job(PrintHostJob &job, PrintHost::ProgressFn prog
fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx); fire_lifecycle_event(LifecycleEvent::UploadStarted, ctx);
} }
bool success = false; bool success = the_job.printhost->upload(std::move(the_job.upload_data),
std::string error; [this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); },
// A throwing upload must not stop the worker, or later jobs would stay queued forever. [this](wxString error) { this->error_fn(std::move(error)); },
try { [this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); }
success = job.printhost->upload(std::move(job.upload_data), std::move(progress_fn), error_fn, std::move(info_fn)); );
} catch (const std::exception &e) {
error = e.what();
error_fn(error);
}
{ {
LifecycleEventContext ctx; LifecycleEventContext ctx;
ctx.name = upload_filename; ctx.name = upload_filename;
ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error; ctx.code = success ? LifecycleEvtCode::Ok : LifecycleEvtCode::Error;
ctx.msg = error;
fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx); fire_lifecycle_event(LifecycleEvent::UploadFinished, ctx);
} }
return success;
}
void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job)
{
emit_progress(0); // Indicate the upload is starting
bool success = PrintHostJobQueue::upload_job(the_job,
[this](Http::Progress progress, bool &cancel) { this->progress_fn(std::move(progress), cancel); },
[this](wxString error) { this->error_fn(std::move(error)); },
[this](wxString tag, wxString host) { this->info_fn(std::move(tag), std::move(host)); }
);
if (success) { if (success) {
emit_progress(100); emit_progress(100);
if (the_job.switch_to_device_tab) { if (the_job.switch_to_device_tab) {
-6
View File
@@ -87,8 +87,6 @@ public:
static PrintHost* get_print_host(DynamicPrintConfig *config); static PrintHost* get_print_host(DynamicPrintConfig *config);
static std::string get_print_host_webui(DynamicPrintConfig *config); static std::string get_print_host_webui(DynamicPrintConfig *config);
// Reads the "err" field of a JSON reply, 0 when absent. Returns -1 when the body is not valid JSON.
static int get_err_code_from_body(const std::string &body);
//Support for cloud webui login //Support for cloud webui login
virtual bool is_cloud() const { return false; } virtual bool is_cloud() const { return false; }
@@ -152,10 +150,6 @@ public:
void enqueue(PrintHostJob job); void enqueue(PrintHostJob job);
void cancel(size_t id); void cancel(size_t id);
// Uploads the job, firing UploadStarted and a matching UploadFinished. An exception thrown by
// the upload is reported through error_fn and makes the upload fail.
static bool upload_job(PrintHostJob &job, PrintHost::ProgressFn progress_fn, PrintHost::ErrorFn error_fn, PrintHost::InfoFn info_fn);
private: private:
struct priv; struct priv;
std::shared_ptr<priv> p; std::shared_ptr<priv> p;
+9
View File
@@ -654,4 +654,13 @@ bool UltiMaker::start_print(wxString &msg, const std::string &filename, Connecti
return res; return res;
} }
int UltiMaker::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} }
+1
View File
@@ -64,6 +64,7 @@ private:
void set_auth(Http& http) const; void set_auth(Http& http) const;
void disconnect(ConnectionType connectionType) const; void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const; bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const;
int get_err_code_from_body(const std::string &body) const;
}; };
} }
+9 -54
View File
@@ -19,8 +19,9 @@ struct LoadedObj
ObjInfo info; ObjInfo info;
}; };
// Loads an OBJ made of the given lines, with a material library that defines material "a". // A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1).
LoadedObj load_textured_obj(const std::string &body) // Only the first face and the vt lines are varied; the other three faces reference vt 1.
LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n")
{ {
ScopedTemporaryFile obj(".obj"); ScopedTemporaryFile obj(".obj");
ScopedTemporaryFile mtl(".mtl"); ScopedTemporaryFile mtl(".mtl");
@@ -30,7 +31,12 @@ LoadedObj load_textured_obj(const std::string &body)
} }
{ {
boost::nowide::ofstream out(obj.string()); boost::nowide::ofstream out(obj.string());
out << "mtllib " << mtl.path().filename().string() << "\n" << body; out << "mtllib " << mtl.path().filename().string() << "\n"
<< "v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n"
<< vts
<< "usemtl a\n"
<< first_face << "\n"
<< "f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n";
} }
LoadedObj loaded; LoadedObj loaded;
std::string message; std::string message;
@@ -38,21 +44,6 @@ LoadedObj load_textured_obj(const std::string &body)
return loaded; return loaded;
} }
// A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1).
// Only the first face and the vt lines are varied; the other three faces reference vt 1.
LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n")
{
return load_textured_obj("v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n" + vts + "usemtl a\n" + first_face + "\n" +
"f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n");
}
// Texture coordinate n is (n / 10, n / 20), so a UV identifies the vt it came from.
void check_uv_is_vt(const Vec2f &uv, int vt)
{
CHECK_THAT(uv.x(), WithinAbs(vt / 10., 1e-6));
CHECK_THAT(uv.y(), WithinAbs(vt / 20., 1e-6));
}
} // namespace } // namespace
TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]") TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]")
@@ -129,39 +120,3 @@ TEST_CASE("A texture coordinate with w does not shift the indices of the ones af
CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6)); CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6)); CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6));
} }
TEST_CASE("Both triangles of a quad take the texture coordinates of their own corners", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_obj("v 0 0 0\nv 10 0 0\nv 10 10 0\nv 0 10 0\n"
"vt 0.1 0.05\nvt 0.2 0.1\nvt 0.3 0.15\nvt 0.4 0.2\n"
"usemtl a\n"
"f 1/1 2/2 3/3 4/4\n");
REQUIRE(loaded.ok);
REQUIRE(loaded.mesh.facets_count() == 2);
REQUIRE(loaded.info.uvs.size() == 2);
check_uv_is_vt(loaded.info.uvs[0][0], 1);
check_uv_is_vt(loaded.info.uvs[0][1], 2);
check_uv_is_vt(loaded.info.uvs[0][2], 3);
check_uv_is_vt(loaded.info.uvs[1][0], 1);
check_uv_is_vt(loaded.info.uvs[1][1], 3);
check_uv_is_vt(loaded.info.uvs[1][2], 4);
}
TEST_CASE("Texture coordinates follow the corners of a mesh that is flipped on load", "[OBJ][Regression]")
{
// The faces wind inwards, so the loader flips them. Vertex n uses vt n.
const LoadedObj loaded = load_textured_obj("v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n"
"vt 0.1 0.05\nvt 0.2 0.1\nvt 0.3 0.15\nvt 0.4 0.2\n"
"usemtl a\n"
"f 1/1 2/2 3/3\nf 1/1 4/4 2/2\nf 1/1 3/3 4/4\nf 2/2 4/4 3/3\n");
REQUIRE(loaded.ok);
const indexed_triangle_set &its = loaded.mesh.its;
CHECK(its_volume(its) > 0.f);
REQUIRE(its.indices.size() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
for (size_t face = 0; face < its.indices.size(); ++face)
for (int corner = 0; corner < 3; ++corner)
check_uv_is_vt(loaded.info.uvs[face][corner], its.indices[face][corner] + 1);
}
-1
View File
@@ -28,7 +28,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_audit.cpp test_plugin_audit.cpp
test_shortcuts.cpp test_shortcuts.cpp
test_file_url.cpp test_file_url.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp ../fff_print/test_helpers.cpp
) )
-161
View File
@@ -1,15 +1,8 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "libslic3r/LifecycleEvents.hpp"
#include "slic3r/Utils/PrintHost.hpp" #include "slic3r/Utils/PrintHost.hpp"
#include "slic3r/Utils/Flashforge.hpp"
using namespace Slic3r; using namespace Slic3r;
@@ -31,41 +24,6 @@ public:
std::string get_host() const override { return {}; } std::string get_host() const override { return {}; }
}; };
class ThrowingPrintHost : public TestPrintHost
{
public:
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { throw std::runtime_error("reply could not be read"); }
};
struct UploadEvents
{
std::vector<LifecycleEvent> events;
std::vector<LifecycleEvtCode> codes;
std::vector<std::string> errors;
bool uploaded{false};
explicit UploadEvents(std::unique_ptr<PrintHost> host)
{
set_lifecycle_hook_fn([this](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back(event);
codes.push_back(ctx.code);
});
PrintHostJob job;
job.printhost = std::move(host);
job.upload_data.source_path = "plate.gcode";
try {
uploaded = PrintHostJobQueue::upload_job(job, [](Http::Progress, bool&) {},
[this](wxString error) { errors.push_back(error.ToStdString()); },
[](wxString, wxString) {});
} catch (...) {
set_lifecycle_hook_fn(nullptr);
throw;
}
set_lifecycle_hook_fn(nullptr);
}
};
std::string format_error(const std::string& body, const std::string& error, unsigned status) std::string format_error(const std::string& body, const std::string& error, unsigned status)
{ {
return TestPrintHost().format_error(body, error, status).ToStdString(); return TestPrintHost().format_error(body, error, status).ToStdString();
@@ -88,14 +46,6 @@ std::string moonraker_error(int code, const std::string& message, const std::str
constexpr const char* k_busy_file_403 = constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON"; R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
// Replies a print host can send instead of JSON: a proxy or login page, nothing, a cut-off body.
const std::vector<std::string> non_json_replies = {
"<html><body>proxy login required</body></html>",
"",
"{\"err\":",
"{\"detail\":{\"matlStationInfo\":{\"slotInfos\":[{\"slotId\":1,",
};
} // namespace } // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]") TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
@@ -261,114 +211,3 @@ TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect"); CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
} }
} }
TEST_CASE("Print host error code is read from a JSON reply", "[PrintHost]")
{
CHECK(PrintHost::get_err_code_from_body(R"({"err":0})") == 0);
CHECK(PrintHost::get_err_code_from_body(R"({"err":2})") == 2);
CHECK(PrintHost::get_err_code_from_body(R"({"status":"ok"})") == 0);
}
TEST_CASE("Print host error code reports a reply that is not JSON as an error", "[PrintHost]")
{
const std::string body = GENERATE(from_range(non_json_replies));
int err = 0;
REQUIRE_NOTHROW(err = PrintHost::get_err_code_from_body(body));
CHECK(err != 0);
}
TEST_CASE("Print host error code tolerates a wrongly typed err field", "[PrintHost]")
{
const std::string body = GENERATE(as<std::string>{}, R"({"err":"busy"})", R"({"err":{"code":1}})", R"([1,2])");
CHECK_NOTHROW(PrintHost::get_err_code_from_body(body));
}
TEST_CASE("Flashforge material slots are read from a well-formed reply", "[PrintHost][Flashforge]")
{
const std::string body = R"({"code":0,"detail":{"hasMatlStation":true,"matlStationInfo":{"slotCnt":2,"slotInfos":[
{"slotId":1,"hasFilament":true,"materialName":"PLA","materialColor":"#FFFFFF"},
{"slotId":2,"hasFilament":false,"materialName":"","materialColor":""}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
bool supports_station = false;
REQUIRE(Flashforge::parse_material_slots(body, slots, &supports_station));
CHECK(supports_station);
REQUIRE(slots.size() == 2);
CHECK(slots[0].slot_id == 1);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name == "PLA");
CHECK(slots[0].material_color == "#FFFFFF");
CHECK(slots[1].slot_id == 2);
CHECK_FALSE(slots[1].has_filament);
}
TEST_CASE("Flashforge material slots accept numbers as strings and flags as numbers", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[
{"slotId":"3","hasFilament":1,"materialName":null,"materialColor":7}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 3);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name.empty());
CHECK(slots[0].material_color.empty());
}
TEST_CASE("Flashforge material slots skip entries that are not objects", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[5,"slot",null,[],
{"slotId":4,"hasFilament":true,"materialName":"PETG"}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 4);
CHECK(slots[0].material_name == "PETG");
}
TEST_CASE("Flashforge material slots tolerate slot info that is not a list", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(as<std::string>{},
R"({"detail":{"matlStationInfo":{"slotInfos":5}}})",
R"({"detail":{"matlStationInfo":{"slotInfos":"none"}}})",
R"({"detail":{"matlStationInfo":7}})",
R"({"detail":"offline"})");
std::vector<FlashforgeMaterialSlot> slots;
bool ok = false;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK(ok);
CHECK(slots.empty());
}
TEST_CASE("Flashforge material slots reject a reply that is not JSON", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(from_range(non_json_replies));
std::vector<FlashforgeMaterialSlot> slots;
bool ok = true;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK_FALSE(ok);
CHECK(slots.empty());
}
TEST_CASE("An upload that throws still finishes with an error", "[PrintHost][LifecycleEvents]")
{
UploadEvents run(std::make_unique<ThrowingPrintHost>());
CHECK_FALSE(run.uploaded);
CHECK(run.errors == std::vector<std::string>{"reply could not be read"});
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Error});
}
TEST_CASE("A successful upload finishes without an error", "[PrintHost][LifecycleEvents]")
{
UploadEvents run(std::make_unique<TestPrintHost>());
CHECK(run.uploaded);
CHECK(run.errors.empty());
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Ok});
}
-23
View File
@@ -1,23 +0,0 @@
#include <catch2/catch_all.hpp>
#include <string>
#include "slic3r/GUI/UserManager.hpp"
using namespace Slic3r;
TEST_CASE("User message that is not JSON is rejected without throwing", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "not json", "", "<html></html>", "{\"bind\":");
UserManager manager;
int result = 0;
REQUIRE_NOTHROW(result = manager.parse_json(payload));
CHECK(result == -1);
}
TEST_CASE("User message without a successful bind is ignored", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "{}", R"({"bind":{"command":"unbind"}})", R"({"bind":"bind"})", "[1]");
UserManager manager;
CHECK(manager.parse_json(payload) == -1);
}