Compare commits

..
Author SHA1 Message Date
Hanif Koh 377034104c Keep OBJ Texture Coordinates That Carry a W Component
The vt parser stopped reading the optional third component when texture
coordinates were cut down to u and v, but the check that nothing is left
on the line stayed. A legal "vt u v w" line was therefore rejected and
silently dropped, shifting every later texture index. The w component is
parsed again and discarded.

The texture coordinate stride is now a named constant, OBJ_TEXCOORD_LENGTH,
used by the parser and the importer, so the relative-index rebase cannot
drift from the storage layout again.
2026-09-28 13:00:25 +08:00
Hanif Koh 468fa3be86 Reject DRC Meshes Without Positions or with Invalid Face Indices
load_drc dereferenced the POSITION attribute without checking that the
mesh has one, and trusted the decoded face indices, which the Draco
decoder does not check against the point count. Both now fail the load
cleanly. A failed vertex conversion is treated the same way.

The libslic3r tests link Draco so they can encode the malformed meshes
in-test.
2026-09-28 13:00:25 +08:00
Hanif Koh 824b092a6e Validate OBJ Texture-Coordinate Indices
load_obj read the texture coordinates of a face without checking the
vt index, so a face referencing a vt past the end of the list read out
of bounds and crashed, and a face vertex with no vt read index -1.
Out-of-range or missing indices now fall back to a zero UV. The face
keeps its entry in the per-face UV list, so the following faces stay
aligned, and the geometry loads as before.

Negative (relative) vt indices were also rebased by dividing the float
count by 3, but each vt stores two floats.
2026-09-28 13:00:25 +08:00
23 changed files with 295 additions and 203 deletions
+21 -8
View File
@@ -48,24 +48,37 @@ bool load_drc(const char *path, TriangleMesh *meshptr)
indexed_triangle_set its; indexed_triangle_set its;
const PointAttribute *const positions = dracoMesh.GetNamedAttribute(GeometryAttribute::POSITION); const PointAttribute *const positions = dracoMesh.GetNamedAttribute(GeometryAttribute::POSITION);
if (positions == nullptr) {
BOOST_LOG_TRIVIAL(error) << "load_drc: the mesh has no POSITION attribute";
return false;
}
size_t num_vertices = positions->size(); size_t num_vertices = positions->size();
its.vertices.reserve(num_vertices); its.vertices.reserve(num_vertices);
for (AttributeValueIndex i(0); i < num_vertices; ++ i) { for (AttributeValueIndex i(0); i < num_vertices; ++ i) {
float pos[3]; float pos[3];
positions->ConvertValue<float>(i, 3, pos); if (!positions->ConvertValue<float>(i, 3, pos)) {
BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex position";
return false;
}
its.vertices.emplace_back(pos[0], pos[1], pos[2]); its.vertices.emplace_back(pos[0], pos[1], pos[2]);
} }
// The Draco decoder does not check face indices against the point count.
const uint32_t num_points = dracoMesh.num_points();
size_t num_faces = dracoMesh.num_faces(); size_t num_faces = dracoMesh.num_faces();
its.indices.reserve(num_faces); its.indices.reserve(num_faces);
for (FaceIndex i(0); i < num_faces; ++ i) { for (FaceIndex i(0); i < num_faces; ++ i) {
Mesh::Face face = dracoMesh.face(i); const Mesh::Face &face = dracoMesh.face(i);
stl_triangle_vertex_indices facet;
its.indices.emplace_back( for (int k = 0; k < 3; ++ k) {
positions->mapped_index(face[0]).value(), const size_t vertex_idx = face[k].value() < num_points ? positions->mapped_index(face[k]).value() : num_vertices;
positions->mapped_index(face[1]).value(), if (vertex_idx >= num_vertices) {
positions->mapped_index(face[2]).value() BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex index";
); return false;
}
facet[k] = static_cast<int>(vertex_idx);
}
its.indices.emplace_back(facet);
} }
*meshptr = TriangleMesh(std::move(its)); *meshptr = TriangleMesh(std::move(its));
+9 -4
View File
@@ -166,10 +166,15 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s
obj_info.uv_map_pngs[face_index] = png_name; obj_info.uv_map_pngs[face_index] = png_name;
} }
if (data.textureCoordinates.size() > 0) { if (data.textureCoordinates.size() > 0) {
Vec2f uv0(data.textureCoordinates[uvs[0] * 2], data.textureCoordinates[uvs[0] * 2 + 1]); // A face vertex may omit vt or reference a missing one. Fall back to (0, 0) rather than
Vec2f uv1(data.textureCoordinates[uvs[1] * 2], data.textureCoordinates[uvs[1] * 2 + 1]); // skipping the face, so obj_info.uvs stays aligned with the face indices.
Vec2f uv2(data.textureCoordinates[uvs[2] * 2], data.textureCoordinates[uvs[2] * 2 + 1]); const int uv_count = static_cast<int>(data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH);
std::array<Vec2f, 3> uv_array{uv0, uv1, uv2}; auto uv_at = [&data, uv_count](int idx) -> Vec2f {
if (idx < 0 || idx >= uv_count)
return Vec2f::Zero();
return Vec2f(data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH], data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH + 1]);
};
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);
+4 -5
View File
@@ -51,19 +51,18 @@ static bool obj_parseline(const char *line, ObjData &data)
line = endptr; line = endptr;
EATWS(); EATWS();
} }
/*double w = 0; // The optional w is accepted but not stored: only u and v are used.
if (*line != 0) { if (*line != 0) {
w = strtod(line, &endptr); strtod(line, &endptr);
if (endptr == 0 || (*endptr != ' ' && *endptr != '\t' && *endptr != 0)) if (endptr == 0 || (*endptr != ' ' && *endptr != '\t' && *endptr != 0))
return false; return false;
line = endptr; line = endptr;
EATWS(); EATWS();
}*/ }
if (*line != 0) if (*line != 0)
return false; return false;
data.textureCoordinates.push_back((float)u); data.textureCoordinates.push_back((float)u);
data.textureCoordinates.push_back((float)v); data.textureCoordinates.push_back((float)v);
//data.textureCoordinates.push_back((float)w);
break; break;
} }
case 'n': case 'n':
@@ -245,7 +244,7 @@ static bool obj_parseline(const char *line, ObjData &data)
else else
-- vertex.normalIdx; -- vertex.normalIdx;
if (vertex.textureCoordIdx < 0) if (vertex.textureCoordIdx < 0)
vertex.textureCoordIdx += (int)data.textureCoordinates.size() / 3; vertex.textureCoordIdx += (int)data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH;
else else
-- vertex.textureCoordIdx; -- vertex.textureCoordIdx;
data.vertices.push_back(vertex); data.vertices.push_back(vertex);
+2 -1
View File
@@ -92,6 +92,7 @@ inline bool operator==(const ObjSmoothingGroup &v1, const ObjSmoothingGroup &v2)
} }
#define OBJ_VERTEX_COLOR_ALPHA 6 #define OBJ_VERTEX_COLOR_ALPHA 6
#define OBJ_VERTEX_LENGTH 7 // x, y, z, color_x,color_y,color_z,color_w #define OBJ_VERTEX_LENGTH 7 // x, y, z, color_x,color_y,color_z,color_w
#define OBJ_TEXCOORD_LENGTH 2 // u, v
#define ONE_FACE_SIZE 4//ONE_FACE format: f 8/4/6 7/3/6 6/2/6 -1/-1/-1 #define ONE_FACE_SIZE 4//ONE_FACE format: f 8/4/6 7/3/6 6/2/6 -1/-1/-1
struct ObjData { struct ObjData {
// Version of the data structure for load / store in the private binary format. // Version of the data structure for load / store in the private binary format.
@@ -100,7 +101,7 @@ struct ObjData {
// x, y, z, color_x,color_y,color_z,color_w // x, y, z, color_x,color_y,color_z,color_w
std::vector<float> coordinates; std::vector<float> coordinates;
bool has_vertex_color{false}; bool has_vertex_color{false};
// u, v, w // u, v
std::vector<float> textureCoordinates; std::vector<float> textureCoordinates;
// x, y, z // x, y, z
std::vector<float> normals; std::vector<float> normals;
+4 -5
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;
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
//bind/unbind //bind/unbind
try { try {
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
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;
}; };
} }
+1 -23
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) {
@@ -320,12 +303,7 @@ void PrintHostJobQueue::priv::bg_thread_main()
% job.cancelled; % job.cancelled;
if (! job.cancelled) { if (! job.cancelled) {
// A failing job must not stop the worker, or later jobs would stay queued forever. perform_job(std::move(job));
try {
perform_job(std::move(job));
} catch (const std::exception &e) {
emit_error(e.what());
}
} }
remove_source(); remove_source();
-2
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; }
+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;
}; };
} }
+5 -1
View File
@@ -14,6 +14,7 @@ add_executable(${_TEST_NAME}_tests
test_clipper_utils.cpp test_clipper_utils.cpp
test_config.cpp test_config.cpp
test_config_variant_expansion.cpp test_config_variant_expansion.cpp
test_drc.cpp
test_toolordering_nozzle_group.cpp test_toolordering_nozzle_group.cpp
test_preset_bundle_loading.cpp test_preset_bundle_loading.cpp
test_preset_setting_id.cpp test_preset_setting_id.cpp
@@ -32,6 +33,7 @@ add_executable(${_TEST_NAME}_tests
test_mutable_priority_queue.cpp test_mutable_priority_queue.cpp
test_minimum_spanning_tree.cpp test_minimum_spanning_tree.cpp
test_nozzle_volume_type.cpp test_nozzle_volume_type.cpp
test_obj.cpp
test_step.cpp test_step.cpp
test_stl.cpp test_stl.cpp
test_triangle_selector.cpp test_triangle_selector.cpp
@@ -67,7 +69,9 @@ if (TARGET OpenVDB::openvdb)
target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp) target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp)
endif() endif()
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) # libslic3r links Draco privately; test_drc.cpp encodes its own fixtures.
find_package(Draco REQUIRED)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r draco::draco Catch2::Catch2WithMain)
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src) target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests") set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
+71
View File
@@ -0,0 +1,71 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Format/DRC.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <boost/nowide/fstream.hpp>
#include <draco/compression/encode.h>
#include <draco/mesh/mesh.h>
#include "test_utils.hpp"
using namespace Slic3r;
namespace {
// Encodes one triangle whose vertices carry a single attribute of the given type.
// The decoder accepts both a mesh without POSITION and a face index past the point count.
void write_drc_triangle(const std::string &path, draco::GeometryAttribute::Type attribute_type, uint32_t second_point)
{
draco::Mesh mesh;
mesh.set_num_points(3);
draco::GeometryAttribute attribute;
attribute.Init(attribute_type, nullptr, 3, draco::DT_FLOAT32, false, sizeof(float) * 3, 0);
const int attribute_id = mesh.AddAttribute(attribute, true, 3);
const float points[3][3] = {{0, 0, 0}, {1, 0, 0}, {0, 1, 0}};
for (int i = 0; i < 3; ++i)
mesh.attribute(attribute_id)->SetAttributeValue(draco::AttributeValueIndex(i), points[i]);
draco::Mesh::Face face;
face[0] = draco::PointIndex(0);
face[1] = draco::PointIndex(second_point);
face[2] = draco::PointIndex(2);
mesh.AddFace(face);
draco::Encoder encoder;
encoder.SetEncodingMethod(draco::MESH_SEQUENTIAL_ENCODING);
draco::EncoderBuffer buffer;
REQUIRE(encoder.EncodeMeshToBuffer(mesh, &buffer).ok());
boost::nowide::ofstream out(path, std::ios::binary);
out.write(buffer.data(), static_cast<std::streamsize>(buffer.size()));
}
} // namespace
TEST_CASE("A Draco triangle with positions loads", "[DRC]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 1);
TriangleMesh mesh;
REQUIRE(load_drc(drc.string().c_str(), &mesh));
CHECK(mesh.facets_count() == 1);
}
TEST_CASE("A Draco mesh without a POSITION attribute fails to load", "[DRC][Regression]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::GENERIC, 1);
TriangleMesh mesh;
CHECK_FALSE(load_drc(drc.string().c_str(), &mesh));
}
TEST_CASE("A Draco face referencing a missing point fails to load", "[DRC][Regression]")
{
ScopedTemporaryFile drc(".drc");
write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 200);
TriangleMesh mesh;
CHECK_FALSE(load_drc(drc.string().c_str(), &mesh));
}
+122
View File
@@ -0,0 +1,122 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Format/OBJ.hpp"
#include <boost/nowide/fstream.hpp>
#include "test_utils.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
struct LoadedObj
{
bool ok{false};
TriangleMesh mesh;
ObjInfo info;
};
// 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")
{
ScopedTemporaryFile obj(".obj");
ScopedTemporaryFile mtl(".mtl");
{
boost::nowide::ofstream out(mtl.string());
out << "newmtl a\nKd 1 0 0\n";
}
{
boost::nowide::ofstream out(obj.string());
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;
std::string message;
loaded.ok = load_obj(obj.string().c_str(), &loaded.mesh, loaded.info, message);
return loaded;
}
} // namespace
TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1/1000000000 3/1 2/1");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0., 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0., 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(0.5, 1e-6));
}
TEST_CASE("A face without texture indices loads among faces that have them", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1 3 2");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
// One UV entry per face, so later faces keep their own coordinates.
REQUIRE(loaded.info.uvs.size() == 4);
for (const Vec2f &uv : loaded.info.uvs.front()) {
CHECK_THAT(uv.x(), WithinAbs(0., 1e-6));
CHECK_THAT(uv.y(), WithinAbs(0., 1e-6));
}
CHECK_THAT(loaded.info.uvs[1][0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(loaded.info.uvs[1][0].y(), WithinAbs(0.5, 1e-6));
}
TEST_CASE("A negative texture index counts back from the last texture coordinate", "[OBJ]")
{
// -1 is the most recent vt (0.75, 1), -2 the one before it (0.25, 0.5).
const LoadedObj loaded = load_textured_tetrahedron("f 1/-2 3/-1 2/-1");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
}
TEST_CASE("Texture coordinates with a w component are kept", "[OBJ][Regression]")
{
const LoadedObj loaded = load_textured_tetrahedron("f 1/1 3/2 2/2", "vt 0.25 0.5 0\nvt 0.75 1 0\n");
REQUIRE(loaded.ok);
CHECK(loaded.mesh.facets_count() == 4);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
}
TEST_CASE("A texture coordinate with w does not shift the indices of the ones after it", "[OBJ][Regression]")
{
// The w on the first vt used to drop that line, so vt 2 resolved to the third coordinate.
const LoadedObj loaded = load_textured_tetrahedron("f 1/2 3/3 2/-1", "vt 0.1 0.2 0\nvt 0.25 0.5\nvt 0.75 1\n");
REQUIRE(loaded.ok);
REQUIRE(loaded.info.uvs.size() == 4);
const std::array<Vec2f, 3> &uv = loaded.info.uvs.front();
CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6));
CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6));
CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6));
CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6));
CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6));
}
-1
View File
@@ -25,7 +25,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_cloud_metadata.cpp test_plugin_cloud_metadata.cpp
test_plugin_audit.cpp test_plugin_audit.cpp
test_shortcuts.cpp test_shortcuts.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp ../fff_print/test_helpers.cpp
) )
-103
View File
@@ -1,12 +1,8 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp" #include "slic3r/Utils/PrintHost.hpp"
#include "slic3r/Utils/Flashforge.hpp"
using namespace Slic3r; using namespace Slic3r;
@@ -50,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]")
@@ -223,94 +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());
}
-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);
}