diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 62fe3c91e4..c5527d5898 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -856,13 +856,30 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) std::vector custom_seam; std::vector mmu_segmentation; std::vector fuzzy_skin; - // One per texture displacement layer slot, each parallel to `triangles` like the masks above. + // One per texture displacement layer slot, indexed by triangle like the masks above, but + // empty for a slot nothing paints and no longer than its last painted triangle. + // See push_texture_displacement(). std::vector texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS]; // BBS std::vector face_properties; bool empty() { return vertices.empty() || triangles.empty(); } + // Records the paint mask of the triangle just appended, for one layer slot. A slot no + // triangle names keeps its vector empty instead of holding one empty string per triangle: + // eight slots over a million triangles would otherwise cost hundreds of megabytes, for + // every project, whether or not it uses the feature. The vector is padded up to the + // triangles seen so far the first time a value turns up, so it stays indexed by triangle, + // and both readers stop at its size, so trailing triangles without a value need nothing. + void push_texture_displacement(int slot, std::string &&mask_string) { + std::vector &mask = texture_displacement[slot]; + if (mask_string.empty() && mask.empty()) + return; + assert(!triangles.empty()); + mask.resize(triangles.size() - 1); + mask.push_back(std::move(mask_string)); + } + // backup & restore void swap(Geometry& o) { std::swap(vertices, o.vertices); @@ -3355,34 +3372,23 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) add_error("Malformed texture displacement data: " + json_path); return; } - // A layer the archive carries no image for - saved while its texture had not been loaded, or - // written by a build that stored only the path - falls back to the file it came from. The - // importer copies every texture into the user's own library, so that path resolves on the - // machine the project was made on and quietly resolves nowhere else, which is the old - // behaviour: settings restored, pixels not. - const auto load_image_from_disk = [](TextureDisplacementLayer &layer) { - boost::system::error_code ec; - if (layer.path.empty() || !boost::filesystem::is_regular_file(layer.path, ec) || ec) - return; - boost::nowide::ifstream file(layer.path, std::ios::binary); - if (!file.good()) - return; - std::vector bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); - if (!bytes.empty()) - layer.image_data = std::make_shared>(std::move(bytes)); - }; - + // The pixels come from the archive and nowhere else. A layer the archive carries no image for - + // saved while its texture had not been loaded, or written by a build that stored only the path - + // keeps its settings without an image, which the user recovers by picking the texture again. + // `layer.path` is left alone on purpose: it names a file on the machine the project was made on, + // so reading it here would let any project choose a local file to open - a UNC path among them - + // and have the next save copy it into the archive. The EmbossShape SVGs are read from the archive + // only, for the same reason. for (TextureDisplacementLayer &layer : layers) { - if (!layer.path_in_3mf.empty()) { - if (const auto image = m_texture_displacement_files.find(layer.path_in_3mf); - image != m_texture_displacement_files.end()) { - layer.image_data = std::make_shared>(image->second.begin(), - image->second.end()); - continue; - } + if (layer.path_in_3mf.empty()) + continue; + const auto image = m_texture_displacement_files.find(layer.path_in_3mf); + if (image == m_texture_displacement_files.end()) { add_error("Missing texture displacement image: " + layer.path_in_3mf); + continue; } - load_image_from_disk(layer); + layer.image_data = std::make_shared>(image->second.begin(), + image->second.end()); } volume.texture_displacement_layers = std::move(layers); volume.texture_displacement_options = options; @@ -4045,8 +4051,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) - m_curr_object->geometry.texture_displacement[slot].push_back( - bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); + m_curr_object->geometry.push_texture_displacement( + slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); // BBS m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -5917,8 +5923,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) - current_object->geometry.texture_displacement[slot].push_back( - bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); + current_object->geometry.push_texture_displacement( + slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); // BBS current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 80aa313f9b..bee2173798 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -5310,101 +5310,108 @@ bool texture_displacement_layers_from_json(const std::string if (!root.is_object()) return false; - TextureDisplacementOptions out_options; - if (const auto it = root.find("options"); it != root.end() && it->is_object()) { - const nlohmann::json &opt = *it; - read(opt, "displace_border", out_options.displace_border); - read(opt, "smooth_enabled", out_options.smooth_enabled); - read(opt, "smooth_strength", out_options.smooth_strength); - read(opt, "smooth_iterations", out_options.smooth_iterations); - read(opt, "smooth_skip_border", out_options.smooth_skip_border); - read(opt, "pipeline_v2", out_options.pipeline_v2); - read(opt, "v2_refine_mm", out_options.v2_refine_mm); - read(opt, "v2_regularize", out_options.v2_regularize); - read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k); - read(opt, "v2_relocate", out_options.v2_relocate); - read(opt, "v2_flip_edges", out_options.v2_flip_edges); - read(opt, "color_mix_enabled", out_options.color_mix_enabled); - read(opt, "color_despeckle", out_options.color_despeckle); - read(opt, "color_mix_count", out_options.color_mix_count); - read(opt, "active_slot", out_options.active_slot); - read(opt, "panel_mode", out_options.panel_mode); - out_options.active_slot = std::clamp(out_options.active_slot, 0, int(TEXTURE_DISPLACEMENT_MAX_LAYERS) - 1); - out_options.panel_mode = std::clamp(out_options.panel_mode, 0, 1); - } - + TextureDisplacementOptions out_options; std::vector out_layers; - if (const auto it = root.find("layers"); it != root.end() && it->is_array()) { - for (const nlohmann::json &j : *it) { - if (!j.is_object()) - continue; - TextureDisplacementLayer l; - read(j, "slot", l.slot); - read(j, "name", l.name); - read(j, "path", l.path); - read(j, "path_in_3mf", l.path_in_3mf); - - read(j, "depth_mm", l.depth_mm); - read(j, "tiling_scale", l.tiling_scale); - read(j, "rotation_deg", l.rotation_deg); - if (const auto o = j.find("offset"); o != j.end()) - l.offset = vec2_from_json(*o, l.offset); - read(j, "invert", l.invert); - read(j, "midlevel", l.midlevel); - read(j, "smoothing", l.smoothing); - - read(j, "edge_smoothing", l.edge_smoothing); - read(j, "edge_smoothing_amount", l.edge_smoothing_amount); - read(j, "auto_connect_islands", l.auto_connect_islands); - - read(j, "tile_enabled", l.tile_enabled); - read(j, "color_enabled", l.color_enabled); - int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); - read_enum(j, "tile_method", tile_method); - read_enum(j, "projection_method", projection); - read_enum(j, "blend_mode", blend); - l.tile_method = TextureTileMethod(tile_method); - l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); - l.blend_mode = TextureBlendMode(blend); - - read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); - read(j, "island_padding_mm", l.island_padding_mm); - - if (const auto v = j.find("view_project_right"); v != j.end()) - l.view_project_right = vec3_from_json(*v, l.view_project_right); - if (const auto v = j.find("view_project_up"); v != j.end()) - l.view_project_up = vec3_from_json(*v, l.view_project_up); - read(j, "view_project_projective", l.view_project_projective); - if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) - for (size_t i = 0; i < 12; ++i) - l.view_project_matrix[i] = (*v)[i].get(); - - if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) - for (size_t i = 0; i + 1 < v->size(); i += 2) - l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); - - if (const auto v = j.find("islands"); v != j.end() && v->is_array()) - for (const nlohmann::json &e : *v) { - if (!e.is_array() || e.size() != 4) - continue; - TextureIsland island; - island.offset = Vec2f(e[0].get(), e[1].get()); - island.rotation_deg = e[2].get(); - island.scale = e[3].get(); - l.islands.push_back(island); - } - - read(j, "island_groups", l.island_groups); - - if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) - for (const nlohmann::json &e : *v) { - if (!e.is_array() || e.size() != 3) - continue; - l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); - } - - out_layers.push_back(std::move(l)); + // Past the parse, a value of the wrong type still throws: get() on a string, get() on + // null. Nothing between here and the project load catches that, so one bad number would fail the + // whole file instead of this one block. Report it malformed and let the volume load without it. + try { + if (const auto it = root.find("options"); it != root.end() && it->is_object()) { + const nlohmann::json &opt = *it; + read(opt, "displace_border", out_options.displace_border); + read(opt, "smooth_enabled", out_options.smooth_enabled); + read(opt, "smooth_strength", out_options.smooth_strength); + read(opt, "smooth_iterations", out_options.smooth_iterations); + read(opt, "smooth_skip_border", out_options.smooth_skip_border); + read(opt, "pipeline_v2", out_options.pipeline_v2); + read(opt, "v2_refine_mm", out_options.v2_refine_mm); + read(opt, "v2_regularize", out_options.v2_regularize); + read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k); + read(opt, "v2_relocate", out_options.v2_relocate); + read(opt, "v2_flip_edges", out_options.v2_flip_edges); + read(opt, "color_mix_enabled", out_options.color_mix_enabled); + read(opt, "color_despeckle", out_options.color_despeckle); + read(opt, "color_mix_count", out_options.color_mix_count); + read(opt, "active_slot", out_options.active_slot); + read(opt, "panel_mode", out_options.panel_mode); + out_options.active_slot = std::clamp(out_options.active_slot, 0, int(TEXTURE_DISPLACEMENT_MAX_LAYERS) - 1); + out_options.panel_mode = std::clamp(out_options.panel_mode, 0, 1); } + + if (const auto it = root.find("layers"); it != root.end() && it->is_array()) { + for (const nlohmann::json &j : *it) { + if (!j.is_object()) + continue; + TextureDisplacementLayer l; + read(j, "slot", l.slot); + read(j, "name", l.name); + read(j, "path", l.path); + read(j, "path_in_3mf", l.path_in_3mf); + + read(j, "depth_mm", l.depth_mm); + read(j, "tiling_scale", l.tiling_scale); + read(j, "rotation_deg", l.rotation_deg); + if (const auto o = j.find("offset"); o != j.end()) + l.offset = vec2_from_json(*o, l.offset); + read(j, "invert", l.invert); + read(j, "midlevel", l.midlevel); + read(j, "smoothing", l.smoothing); + + read(j, "edge_smoothing", l.edge_smoothing); + read(j, "edge_smoothing_amount", l.edge_smoothing_amount); + read(j, "auto_connect_islands", l.auto_connect_islands); + + read(j, "tile_enabled", l.tile_enabled); + read(j, "color_enabled", l.color_enabled); + int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); + read_enum(j, "tile_method", tile_method); + read_enum(j, "projection_method", projection); + read_enum(j, "blend_mode", blend); + l.tile_method = TextureTileMethod(tile_method); + l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); + l.blend_mode = TextureBlendMode(blend); + + read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); + read(j, "island_padding_mm", l.island_padding_mm); + + if (const auto v = j.find("view_project_right"); v != j.end()) + l.view_project_right = vec3_from_json(*v, l.view_project_right); + if (const auto v = j.find("view_project_up"); v != j.end()) + l.view_project_up = vec3_from_json(*v, l.view_project_up); + read(j, "view_project_projective", l.view_project_projective); + if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) + for (size_t i = 0; i < 12; ++i) + l.view_project_matrix[i] = (*v)[i].get(); + + if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) + for (size_t i = 0; i + 1 < v->size(); i += 2) + l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); + + if (const auto v = j.find("islands"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 4) + continue; + TextureIsland island; + island.offset = Vec2f(e[0].get(), e[1].get()); + island.rotation_deg = e[2].get(); + island.scale = e[3].get(); + l.islands.push_back(island); + } + + read(j, "island_groups", l.island_groups); + + if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 3) + continue; + l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); + } + + out_layers.push_back(std::move(l)); + } + } + } catch (...) { + return false; } layers = std::move(out_layers); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f5ac340c58..44a6b04a2d 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -28,9 +28,12 @@ #include #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleSelector.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/PNGReadWrite.hpp" #include #include #include +#include #include #include @@ -349,6 +352,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf return replaced; } +// Writes `model` out as a one-plate project, which is what the application's Save does with it. +static void store_project(const std::string& path, Model& model) +{ + ScopedTemporaryDir backup_dir("orca_project_src"); + model.set_backup_path(backup_dir.string()); + + DynamicPrintConfig cfg; + PlateData plate; + plate.plate_index = 0; + StoreParams sp; + sp.path = path.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + sp.plate_data_list.push_back(&plate); + REQUIRE(store_bbs_3mf(sp)); +} + // Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and // Extruder3, which the exporter writes as paint_color="8" and paint_color="0C". static void store_painted_cube(const std::string& path) @@ -363,19 +384,7 @@ static void store_painted_cube(const std::string& path) selector.set_facet(1, EnforcerBlockerType::Extruder3); REQUIRE(volume->mmu_segmentation_facets.set(selector)); } - ScopedTemporaryDir backup_dir("orca_paint_src"); - model.set_backup_path(backup_dir.string()); - - DynamicPrintConfig cfg; - PlateData plate; - plate.plate_index = 0; - StoreParams sp; - sp.path = path.c_str(); - sp.model = &model; - sp.config = &cfg; - sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; - sp.plate_data_list.push_back(&plate); - REQUIRE(store_bbs_3mf(sp)); + store_project(path, model); } // Loads `path` through the BBS importer into `model`, releasing the plates it returns. The @@ -2080,3 +2089,307 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json"); release_PlateData_list(dst_plates); } + +// Encodes a flat grayscale PNG the way the texture panel's import does, so what the archive carries +// is a real texture rather than an opaque blob. +static std::shared_ptr> make_gray_png(uint8_t value, size_t w = 4, size_t h = 4) +{ + ScopedTemporaryFile file(".png"); + const std::vector pixels(w * h, value); + REQUIRE(png::write_gray_to_file(file.string(), w, h, pixels)); + + boost::nowide::ifstream in(file.string(), std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +// Every entry name in a 3mf archive, with the separators normalised the way the importer sees them. +static std::vector zip_entry_names(const std::string& path) +{ + std::vector names; + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_reader(&zip, path)); + const mz_uint n = mz_zip_reader_get_num_files(&zip); + for (mz_uint i = 0; i < n; ++i) { + mz_zip_archive_file_stat st; + if (!mz_zip_reader_file_stat(&zip, i, &st) || st.m_is_directory) + continue; + std::string name(st.m_filename); + std::replace(name.begin(), name.end(), '\\', '/'); + names.push_back(std::move(name)); + } + close_zip_reader(&zip); + return names; +} + +static const std::string TEXTURE_DISPLACEMENT_ENTRY_DIR = "Metadata/texture_displacement/"; + +// Stores a one-plate project holding a cube with one texture displacement layer, painted on that +// layer's own slot. The caller fills the layer in, and gets back the mask string the exporter wrote +// for `painted_facet`, which is what the load has to reproduce. +// +// The painted facet is deliberately not the first one: the importer keeps a slot's masks only as far +// as its last painted triangle, so a facet in the middle is the case where that run has to be padded +// in front and left short at the back. +static void store_texture_displaced_cube(const std::string& path, const TextureDisplacementLayer& layer, + const TextureDisplacementOptions& options, std::string& painted_mask, + int painted_facet = 5) +{ + Model model; + ModelObject* object = model.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + + volume->texture_displacement_layers.push_back(layer); + volume->texture_displacement_options = options; + REQUIRE(painted_facet < int(volume->mesh().its.indices.size())); + { + TriangleSelector selector(volume->mesh()); + selector.set_facet(painted_facet, EnforcerBlockerType::ENFORCER); + REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector)); + } + painted_mask = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet); + REQUIRE_FALSE(painted_mask.empty()); + + store_project(path, model); +} + +// Returns the only volume of the only object in the project at `path`, loaded through the importer. +static const ModelVolume& load_single_volume(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir) +{ + REQUIRE(load_project(path, model, backup_dir)); + REQUIRE(model.objects.size() == 1); + REQUIRE(model.objects.front()->volumes.size() == 1); + return *model.objects.front()->volumes.front(); +} + +// A texture displacement stack is the one paint tool whose settings live outside the volume's config: +// the layer stack goes into the archive as its own JSON file, each layer's texture as a PNG beside it, +// and the paint as a per-triangle attribute (see add_texture_displacement). A project only reopens +// with its relief intact if all three come back together. +TEST_CASE("A texture displacement layer, its texture and its paint survive a project round trip", "[3mf][TextureDisplacement]") +{ + TextureDisplacementLayer layer; + layer.slot = 3; // not the first slot, so the per-slot attribute names have to line up + layer.name = "Bark"; + layer.path = "/textures/bark.png"; + layer.depth_mm = 1.25f; + layer.tiling_scale = 7.5f; + layer.midlevel = 0.5f; + layer.invert = true; + layer.tile_method = TextureTileMethod::MirroredRepeat; + layer.projection_method = TextureProjectionMethod::LSCM; + layer.blend_mode = TextureBlendMode::Subtract; + layer.lscm_seam_edges = { { 1, 2 }, { 3, 5 } }; + layer.image_data = make_gray_png(128); + + TextureDisplacementOptions options; + options.smooth_enabled = true; + options.smooth_iterations = 4; + options.active_slot = layer.slot; + options.panel_mode = 1; + + const int painted_facet = 5; + std::string painted; + ScopedTemporaryFile temp(".3mf"); + store_texture_displaced_cube(temp.string(), layer, options, painted, painted_facet); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir); + + REQUIRE(volume.texture_displacement_layers.size() == 1); + const TextureDisplacementLayer& read = volume.texture_displacement_layers.front(); + CHECK(read.slot == layer.slot); + CHECK(read.name == layer.name); + CHECK(read.path == layer.path); + CHECK_THAT(read.depth_mm, Catch::Matchers::WithinAbs(layer.depth_mm, 1e-6f)); + CHECK_THAT(read.tiling_scale, Catch::Matchers::WithinAbs(layer.tiling_scale, 1e-6f)); + CHECK_THAT(read.midlevel, Catch::Matchers::WithinAbs(layer.midlevel, 1e-6f)); + CHECK(read.invert == layer.invert); + CHECK(read.tile_method == layer.tile_method); + CHECK(read.projection_method == layer.projection_method); + CHECK(read.blend_mode == layer.blend_mode); + CHECK(read.lscm_seam_edges == layer.lscm_seam_edges); + + // The texture rides along in the archive, named after the slot it belongs to, byte for byte. + CHECK(boost::algorithm::starts_with(read.path_in_3mf, TEXTURE_DISPLACEMENT_ENTRY_DIR)); + CHECK(boost::algorithm::ends_with(read.path_in_3mf, "_" + std::to_string(layer.slot) + ".png")); + REQUIRE(read.image_data); + CHECK(*read.image_data == *layer.image_data); + + CHECK(volume.texture_displacement_options.smooth_enabled); + CHECK(volume.texture_displacement_options.smooth_iterations == options.smooth_iterations); + CHECK(volume.texture_displacement_options.active_slot == options.active_slot); + CHECK(volume.texture_displacement_options.panel_mode == options.panel_mode); + + // The paint comes back on the facet it was put on, on its own slot and nowhere else. + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted); + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(0).empty()); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + if (slot != layer.slot) + CHECK(volume.texture_displacement_facet(slot).empty()); +} + +// A layer names the file its texture was imported from, which the library panel uses to point at the +// entry the layer came from. That name must not be opened: the project may come from anyone, the path +// may name any file the user can read - on Windows a UNC path names a host as well - and the next save +// would copy whatever was found there into the archive. A layer the archive has no image for comes +// back without pixels instead, which the user fixes by picking the texture again. +TEST_CASE("A texture displacement layer whose image the archive lacks is not read from the path it names", "[3mf][TextureDisplacement][Regression]") +{ + // A file that really is there, so the test fails if the importer ever reaches for it. + ScopedTemporaryFile texture(".png"); + REQUIRE(png::write_gray_to_file(texture.string(), 4, 4, std::vector(16, 200))); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = "Bark"; + layer.path = texture.string(); // and no image_data: nothing for the exporter to put in the archive + + std::string painted; + ScopedTemporaryFile temp(".3mf"); + store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted); + + for (const std::string& name : zip_entry_names(temp.string())) + if (boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR)) + CHECK_FALSE(boost::algorithm::ends_with(name, ".png")); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir); + + REQUIRE(volume.texture_displacement_layers.size() == 1); + CHECK(volume.texture_displacement_layers.front().path == texture.string()); + CHECK_FALSE(bool(volume.texture_displacement_layers.front().image_data)); + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted); +} + +// The stack's JSON is arbitrary text by the time it is read back, and a value of the wrong type in it +// throws out of the parse. That has to cost the volume its layers, not the project: the model, and the +// paint the user can still build on, load as they would from a build without the feature. +TEST_CASE("A malformed texture displacement block leaves the rest of the project loadable", "[3mf][TextureDisplacement][Regression]") +{ + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = "Bark"; + layer.image_data = make_gray_png(128); + + std::string painted; + ScopedTemporaryFile temp(".3mf"); + store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted); + + REQUIRE(rewrite_3mf_entries(temp.string(), [](std::string& name, std::string& data) { + if (!boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR) || + !boost::algorithm::ends_with(name, ".json")) + return false; + // `offset` goes through a typed accessor, so a string in it throws; a plain setting like + // depth_mm would be shrugged off by the parse instead, leaving the layer loadable. + data = R"({"version":1,"layers":[{"slot":0,"offset":["deep","deep"]}]})"; + return true; + })); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + bool loaded = false; + REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir)); + REQUIRE(loaded); + REQUIRE(model.objects.size() == 1); + REQUIRE(model.objects.front()->volumes.size() == 1); + const ModelVolume& volume = *model.objects.front()->volumes.front(); + + CHECK(volume.texture_displacement_layers.empty()); + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted); +} + +// A slot's masks are kept only as far as its last painted triangle, so where the paint falls in the +// list decides whether that run has to be padded in front of the paint, cut short behind it, or both. +TEST_CASE("Texture displacement paint survives wherever it falls in the triangle list", "[3mf][TextureDisplacement]") +{ + const int painted_facet = GENERATE(0, 5, 11); // first, middle and last facet of a cube + INFO("painted facet " << painted_facet); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = "Bark"; + layer.image_data = make_gray_png(128); + + std::string painted; + ScopedTemporaryFile temp(".3mf"); + store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted, painted_facet); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir); + + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted); + for (int facet = 0; facet < int(volume.mesh().its.indices.size()); ++facet) + if (facet != painted_facet) + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(facet).empty()); +} + +// Each volume's stack is written under the volume's own id, and the paint of a whole object arrives as +// one run of triangles that the volumes take their slices of. Two volumes using the same slot are the +// case where both of those have to tell the volumes apart. +TEST_CASE("Two volumes of one object keep their own texture displacement layers", "[3mf][TextureDisplacement]") +{ + const auto first_image = make_gray_png(64); + const auto second_image = make_gray_png(192); + REQUIRE(*first_image != *second_image); + + // The facet each volume is painted on, volume-local, so a mask landing on the wrong volume shows up. + const int painted_facets[2] = { 0, 7 }; + std::string painted[2]; + + ScopedTemporaryFile temp(".3mf"); + { + Model model; + ModelObject* object = model.add_object(); + object->add_instance(); + for (int i = 0; i < 2; ++i) { + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + TextureDisplacementLayer layer; + layer.slot = 0; // the same slot in both, which is what makes their names collide + layer.name = i == 0 ? "First" : "Second"; + layer.depth_mm = i == 0 ? 0.5f : 1.5f; + layer.image_data = i == 0 ? first_image : second_image; + volume->texture_displacement_layers.push_back(layer); + + TriangleSelector selector(volume->mesh()); + selector.set_facet(painted_facets[i], EnforcerBlockerType::ENFORCER); + REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector)); + painted[i] = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]); + REQUIRE_FALSE(painted[i].empty()); + } + store_project(temp.string(), model); + } + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 1); + REQUIRE(model.objects.front()->volumes.size() == 2); + + const std::shared_ptr> images[2] = { first_image, second_image }; + const std::string names[2] = { "First", "Second" }; + const float depths[2] = { 0.5f, 1.5f }; + std::string entries[2]; + for (int i = 0; i < 2; ++i) { + INFO("volume " << i); + const ModelVolume& volume = *model.objects.front()->volumes[i]; + REQUIRE(volume.texture_displacement_layers.size() == 1); + const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front(); + CHECK(layer.name == names[i]); + CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(depths[i], 1e-6f)); + REQUIRE(layer.image_data); + CHECK(*layer.image_data == *images[i]); + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]) == painted[i]); + CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[1 - i]).empty()); + entries[i] = layer.path_in_3mf; + } + // Both volumes paint slot 0, so only the volume id keeps their textures apart in the archive. + CHECK(entries[0] != entries[1]); +} + diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index fefc38ac33..5b5ee3d790 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2300,6 +2300,47 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options)); } +// The vectors, the islands and the UV overrides are read through nlohmann's typed accessors, which +// throw on a value of the wrong type rather than returning anything. Nothing between here and the +// project load catches that, so a document carrying one has to come back as "malformed block". +TEST_CASE("Texture displacement JSON reports a value of the wrong type instead of throwing", "[TextureDisplacement]") +{ + const std::string json = GENERATE( + R"({"layers":[{"slot":0,"offset":["a","b"]}]})", + R"({"layers":[{"slot":0,"view_project_right":[1,null,3]}]})", + R"({"layers":[{"slot":0,"view_project_matrix":[1,2,3,4,5,6,7,8,9,10,11,"x"]}]})", + R"({"layers":[{"slot":0,"lscm_seam_edges":["a","b"]}]})", + R"({"layers":[{"slot":0,"islands":[[1,2,"a",4]]}]})", + R"({"layers":[{"slot":0,"lscm_uv_overrides":[[1,"a",3]]}]})"); + INFO(json); + + std::vector layers; + TextureDisplacementOptions options; + bool parsed = true; + REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(json, layers, options)); + CHECK_FALSE(parsed); + CHECK(layers.empty()); +} + +// A plain setting is read through a helper that shrugs a wrong type off, which is the deliberate +// other half of the case above: the field keeps its default and the rest of the layer still loads. +TEST_CASE("Texture displacement JSON keeps the default for a setting of the wrong type", "[TextureDisplacement]") +{ + const std::string json = R"({"options":{"smooth_strength":"a lot"},"layers":[{"slot":"two","name":"Old","depth_mm":0.75}]})"; + + std::vector layers; + TextureDisplacementOptions options; + REQUIRE(texture_displacement_layers_from_json(json, layers, options)); + REQUIRE(layers.size() == 1); + + const TextureDisplacementLayer fresh; + const TextureDisplacementOptions defaults; + CHECK(layers[0].slot == fresh.slot); + CHECK(layers[0].name == "Old"); + CHECK_THAT(layers[0].depth_mm, WithinAbs(0.75f, 1e-6f)); + CHECK_THAT(options.smooth_strength, WithinAbs(defaults.smooth_strength, 1e-6f)); +} + TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]") { TextureDisplacementLayer gray;