From fcb1f62bd93cd89bb822bb10d258fec138731ab7 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 5 Oct 2026 08:36:22 +0200 Subject: [PATCH 1/4] Save texture displacement in the project file A painted relief did not survive closing the project: nothing about the feature was written to the .3mf, so the layers, their textures and the paint mask were all lost on save. The layer struct's cereal save()/load() serve the undo/redo stack only, which is one session and one binary stream. Write the layer stack and the per-volume options as JSON in the archive, and each layer's texture as the image file it was loaded from, both referenced by path - the same split EmbossShape already makes for its SVG. The image stays out of the XML deliberately: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file every reader parses just to list the objects. JSON rather than the cereal stream next to it because that one is positional and unversioned, which would make every future field a project-breaking change; an unknown key is ignored and a missing one keeps its default, so a project written by either side keeps loading. The paint masks go in as one attribute per layer slot, mirroring paint_color. Older readers ignore attributes they do not know, so a project written here still opens in a build without the feature - it loses the relief, which is all it could have done with it anyway. The layers are restored while the volume is being built rather than after the archive is walked: volumes are constructed only once the whole archive has been read, so a hook placed after the walk ran before any volume existed and restored nothing. --- src/libslic3r/Format/bbs_3mf.cpp | 152 ++++++++++- src/libslic3r/TextureDisplacement.cpp | 242 ++++++++++++++++++ src/libslic3r/TextureDisplacement.hpp | 17 ++ tests/libslic3r/test_texture_displacement.cpp | 136 ++++++++++ 4 files changed, 546 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 160c169d69..72e3d979da 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -298,6 +298,13 @@ static constexpr const char* CUSTOM_SUPPORTS_ATTR = "paint_supports"; static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin"; static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam"; static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color"; +// Texture displacement. One paint mask per layer slot, mirroring paint_color; the layer stack itself is +// a JSON file in the archive, named by the volume metadata key below (see add_texture_displacement()). +static constexpr const char* TEXTURE_DISPLACEMENT_ATTRS[Slic3r::TEXTURE_DISPLACEMENT_MAX_LAYERS] = { + "paint_texture_0", "paint_texture_1", "paint_texture_2", "paint_texture_3", + "paint_texture_4", "paint_texture_5", "paint_texture_6", "paint_texture_7" }; +static constexpr const char* TEXTURE_DISPLACEMENT_KEY = "texture_displacement"; +static constexpr const char* TEXTURE_DISPLACEMENT_DIR = "Metadata/texture_displacement/"; // BBS static constexpr const char* FACE_PROPERTY_ATTR = "face_property"; @@ -785,6 +792,8 @@ 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. + std::vector texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS]; // BBS std::vector face_properties; @@ -805,6 +814,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) custom_seam.clear(); mmu_segmentation.clear(); fuzzy_skin.clear(); + for (std::vector &slot : texture_displacement) + slot.clear(); } }; @@ -1138,6 +1149,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) /*IdToSlaSupportPointsMap m_sla_support_points; IdToSlaDrainHolesMap m_sla_drain_holes;*/ PathToEmbossShapeFileMap m_path_to_emboss_shape_files; + // Texture displacement: every file under Metadata/texture_displacement/, by archive path. Volumes + // are built only after the whole archive has been walked, so by the time a volume names its JSON + // every file it could refer to is already in here, whatever order the archive happened to be in. + std::map m_texture_displacement_files; + void _apply_texture_displacement(ModelVolume &volume, const std::string &json_path); std::string m_curr_metadata_name; std::string m_curr_characters; std::string m_name; @@ -2004,6 +2020,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) else if (_is_svg_shape_file(name)) { _extract_embossed_svg_shape_file(name, archive, stat); } + else if (boost::algorithm::istarts_with(name, TEXTURE_DISPLACEMENT_DIR)) { + std::string contents(stat.m_uncomp_size, '\0'); + if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, contents.data(), stat.m_uncomp_size, 0)) + m_texture_displacement_files.emplace(name, std::move(contents)); + else + add_error("Error while reading texture displacement data"); + } else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) { m_parsing_slice_info = true; //extract slice info from archive @@ -3239,6 +3262,41 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) } }*/ + // Restores one volume's texture displacement stack from the JSON it named. Called while the volume is + // being built, which is after the whole archive has been walked - so every file it can refer to is + // already extracted, and no deferral is needed. + // + // A missing or malformed JSON leaves the volume with no layers but keeps the paint mask it already + // loaded, which is the same state as a project saved by a build without the feature: recoverable by + // picking the texture again rather than a hard failure. + void _BBS_3MF_Importer::_apply_texture_displacement(ModelVolume &volume, const std::string &json_path) + { + const auto json = m_texture_displacement_files.find(json_path); + if (json == m_texture_displacement_files.end()) { + add_error("Missing texture displacement data: " + json_path); + return; + } + std::vector layers; + TextureDisplacementOptions options; + if (!texture_displacement_layers_from_json(json->second, layers, options)) { + add_error("Malformed texture displacement data: " + json_path); + return; + } + for (TextureDisplacementLayer &layer : layers) { + 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; + } + layer.image_data = std::make_shared>(image->second.begin(), + image->second.end()); + } + volume.texture_displacement_layers = std::move(layers); + volume.texture_displacement_options = options; + } + void _BBS_3MF_Importer::_extract_embossed_svg_shape_file(const std::string &filename, mz_zip_archive &archive, const mz_zip_archive_file_stat &stat){ assert(m_path_to_emboss_shape_files.find(filename) == m_path_to_emboss_shape_files.end()); auto file = std::make_unique(stat.m_uncomp_size, '\0'); @@ -3888,6 +3946,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR)); 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])); // BBS m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -5214,6 +5275,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume->mmu_segmentation_facets.touch(); volume->fuzzy_skin_facets.shrink_to_fit(); volume->fuzzy_skin_facets.touch(); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + const std::vector &mask = sub_object->geometry.texture_displacement[slot]; + if (mask.empty()) + continue; // written by a build without the feature + FacetsAnnotation &facets = volume->texture_displacement_facet(slot); + facets.reserve(triangles_count); + for (size_t i = 0; i < triangles_count && i < mask.size(); ++i) + if (!mask[i].empty()) + facets.set_triangle_from_string(i, mask[i]); + facets.shrink_to_fit(); + facets.touch(); + } } volume->set_type(volume_data->part_type); @@ -5229,6 +5302,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) for (const Metadata& metadata : volume_data->metadata) { if (metadata.key == NAME_KEY) volume->name = metadata.value; + else if (metadata.key == TEXTURE_DISPLACEMENT_KEY) + _apply_texture_displacement(*volume, metadata.value); //else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1")) // volume->set_type(ModelVolumeType::PARAMETER_MODIFIER); //for old format @@ -5393,12 +5468,28 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume->seam_facets.shrink_to_fit(); volume->mmu_segmentation_facets.shrink_to_fit(); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + const std::vector &mask = geometry.texture_displacement[slot]; + if (mask.empty()) + continue; // written by a build without the feature + FacetsAnnotation &facets = volume->texture_displacement_facet(slot); + facets.reserve(triangles_count); + for (size_t i = 0; i < triangles_count; ++i) { + const size_t index = volume_data.first_triangle_id + i; + if (index < mask.size() && !mask[index].empty()) + facets.set_triangle_from_string(i, mask[index]); + } + facets.shrink_to_fit(); + facets.touch(); + } volume->set_type(volume_data.part_type); // apply the remaining volume's metadata for (const Metadata& metadata : volume_data.metadata) { if (metadata.key == NAME_KEY) volume->name = metadata.value; + else if (metadata.key == TEXTURE_DISPLACEMENT_KEY) + _apply_texture_displacement(*volume, metadata.value); //else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1")) // volume->set_type(ModelVolumeType::PARAMETER_MODIFIER); //for old format @@ -5695,6 +5786,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR)); 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])); // BBS current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -6698,6 +6792,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << " \n"; stream << " \n"; stream << " \n"; + stream << " \n"; + stream << " \n"; + stream << " \n"; stream << " \n"; stream << ""; @@ -7541,6 +7638,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) output_buffer += "\""; } + // One attribute per texture displacement layer slot. Older readers ignore attributes they do + // not know, so a project written here still opens in a build without the feature - it just + // loses the paint, which is also all it could have done with it. + for (int slot = 0; slot < TEXTURE_DISPLACEMENT_MAX_LAYERS; ++slot) { + const std::string texture_paint = volume->texture_displacement_facet(slot).get_triangle_as_string(i); + if (texture_paint.empty()) + continue; + output_buffer += " "; + output_buffer += TEXTURE_DISPLACEMENT_ATTRS[slot]; + output_buffer += "=\""; + output_buffer += texture_paint; + output_buffer += "\""; + } + // BBS if (i < its.properties.size()) { std::string prop_str = its.properties[i].to_string(); @@ -7990,7 +8101,44 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return file_path; } - bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id) + // Writes a volume's texture displacement stack into the archive and points the volume's metadata at it. +// The layer settings go in as JSON and each layer's texture as the image file it was loaded from, both +// referenced by path - the same split EmbossShape makes for its SVG. The image deliberately stays out of +// the XML: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file +// every reader has to parse just to list the objects. +static void add_texture_displacement(std::stringstream &stream, const ModelVolume &volume, mz_zip_archive &archive, + const std::string &id) +{ + if (volume.texture_displacement_layers.empty()) + return; + + // A copy, because path_in_3mf is only meaningful inside the archive being written and the volume + // being exported is const (and may be saved again, elsewhere, with different paths). + std::vector layers = volume.texture_displacement_layers; + for (TextureDisplacementLayer &layer : layers) { + layer.path_in_3mf.clear(); + if (!layer.image_data || layer.image_data->empty()) + continue; + std::string ext = boost::filesystem::path(layer.path).extension().string(); + boost::to_lower(ext); + if (ext != ".png" && ext != ".jpg" && ext != ".jpeg") + ext = ".png"; // the library ships PNG; anything unrecognised is stored under a type a reader expects + const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ext; + // No deflate: PNG and JPEG are already compressed, so a second pass only costs time. + if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(), + MZ_NO_COMPRESSION)) + layer.path_in_3mf = path; + } + + const std::string json = texture_displacement_layers_to_json(layers, volume.texture_displacement_options); + const std::string json_path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + ".json"; + if (!mz_zip_writer_add_mem(&archive, json_path.c_str(), json.data(), json.size(), MZ_DEFAULT_COMPRESSION)) + return; + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TEXTURE_DISPLACEMENT_KEY << "\" " + << VALUE_ATTR << "=\"" << xml_escape(json_path) << "\"/>\n"; +} + +bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id) { std::stringstream stream; // Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back @@ -8101,6 +8249,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (const std::optional &es = volume->emboss_shape; es.has_value()) { to_xml(stream, *es, *volume, archive, m_fullpath_sources); } + + add_texture_displacement(stream, *volume, archive, std::to_string(volume->id().id)); if (const std::optional &tc = volume->text_configuration; tc.has_value()) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 862c6c9044..86ae498567 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "nlohmann/json.hpp" #include #include #include @@ -5041,4 +5042,245 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s return out; } +// --------------------------------------------------------------------------------------------- +// Project persistence. See the declarations in TextureDisplacement.hpp for why this is JSON rather +// than the cereal stream next to them. +// --------------------------------------------------------------------------------------------- +namespace { + +nlohmann::json vec2_to_json(const Vec2f &v) { return nlohmann::json::array({ v.x(), v.y() }); } +nlohmann::json vec3_to_json(const Vec3f &v) { return nlohmann::json::array({ v.x(), v.y(), v.z() }); } + +Vec2f vec2_from_json(const nlohmann::json &j, const Vec2f &fallback) +{ + if (!j.is_array() || j.size() != 2) + return fallback; + return Vec2f(j[0].get(), j[1].get()); +} +Vec3f vec3_from_json(const nlohmann::json &j, const Vec3f &fallback) +{ + if (!j.is_array() || j.size() != 3) + return fallback; + return Vec3f(j[0].get(), j[1].get(), j[2].get()); +} + +// Reads key `k` into `out` when it is present and of the expected type, leaving `out` alone otherwise. +// That "leave it alone" is the whole point: every member starts at its struct default, so a project +// written by an older build simply keeps the defaults for whatever it did not know about. +template void read(const nlohmann::json &j, const char *k, T &out) +{ + const auto it = j.find(k); + if (it == j.end()) + return; + try { + out = it->get(); + } catch (...) { + } +} +void read_enum(const nlohmann::json &j, const char *k, int &out) +{ + const auto it = j.find(k); + if (it != j.end() && it->is_number_integer()) + out = it->get(); +} + +} // namespace + +std::string texture_displacement_layers_to_json(const std::vector &layers, + const TextureDisplacementOptions &options) +{ + nlohmann::json root; + root["version"] = 1; + + nlohmann::json &opt = root["options"]; + opt["displace_border"] = options.displace_border; + opt["smooth_enabled"] = options.smooth_enabled; + opt["smooth_strength"] = options.smooth_strength; + opt["smooth_iterations"] = options.smooth_iterations; + opt["smooth_skip_border"] = options.smooth_skip_border; + opt["pipeline_v2"] = options.pipeline_v2; + opt["v2_refine_mm"] = options.v2_refine_mm; + opt["v2_regularize"] = options.v2_regularize; + opt["v2_max_triangles_k"] = options.v2_max_triangles_k; + opt["v2_relocate"] = options.v2_relocate; + opt["v2_flip_edges"] = options.v2_flip_edges; + opt["color_mix_enabled"] = options.color_mix_enabled; + opt["color_mix_mode"] = int(options.color_mix_mode); + opt["color_despeckle"] = options.color_despeckle; + + nlohmann::json &arr = root["layers"]; + arr = nlohmann::json::array(); + for (const TextureDisplacementLayer &l : layers) { + nlohmann::json j; + j["slot"] = l.slot; + j["name"] = l.name; + j["path"] = l.path; + j["path_in_3mf"] = l.path_in_3mf; + + j["depth_mm"] = l.depth_mm; + j["tiling_scale"] = l.tiling_scale; + j["rotation_deg"] = l.rotation_deg; + j["offset"] = vec2_to_json(l.offset); + j["invert"] = l.invert; + j["midlevel"] = l.midlevel; + j["smoothing"] = l.smoothing; + + j["edge_smoothing"] = l.edge_smoothing; + j["edge_smoothing_amount"] = l.edge_smoothing_amount; + j["auto_connect_islands"] = l.auto_connect_islands; + + j["tile_enabled"] = l.tile_enabled; + j["tile_method"] = int(l.tile_method); + j["projection_method"] = int(l.projection_method); + j["blend_mode"] = int(l.blend_mode); + j["color_enabled"] = l.color_enabled; + + j["lscm_seam_angle_deg"] = l.lscm_seam_angle_deg; + j["island_padding_mm"] = l.island_padding_mm; + + j["view_project_right"] = vec3_to_json(l.view_project_right); + j["view_project_up"] = vec3_to_json(l.view_project_up); + j["view_project_projective"] = l.view_project_projective; + j["view_project_matrix"] = l.view_project_matrix; + + // Flat pairs rather than nested arrays: shorter, and the reader can simply ignore a trailing + // odd element instead of having to validate every sub-array. + nlohmann::json &seams = j["lscm_seam_edges"]; + seams = nlohmann::json::array(); + for (const auto &[a, b] : l.lscm_seam_edges) { + seams.push_back(a); + seams.push_back(b); + } + + nlohmann::json &islands = j["islands"]; + islands = nlohmann::json::array(); + for (const TextureIsland &island : l.islands) + islands.push_back(nlohmann::json::array( + { island.offset.x(), island.offset.y(), island.rotation_deg, island.scale })); + + j["island_groups"] = l.island_groups; + + nlohmann::json &overrides = j["lscm_uv_overrides"]; + overrides = nlohmann::json::array(); + for (const auto &[key, uv] : l.lscm_uv_overrides) + overrides.push_back(nlohmann::json::array({ key, uv.x(), uv.y() })); + + arr.push_back(std::move(j)); + } + return root.dump(); +} + +bool texture_displacement_layers_from_json(const std::string &text, + std::vector &layers, + TextureDisplacementOptions &options) +{ + nlohmann::json root; + try { + root = nlohmann::json::parse(text); + } catch (...) { + return false; + } + 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); + int mix_mode = int(out_options.color_mix_mode); + read_enum(opt, "color_mix_mode", mix_mode); + out_options.color_mix_mode = ColorMixMode(std::clamp(mix_mode, 0, 2)); + } + + 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)); + } + } + + layers = std::move(out_layers); + options = out_options; + return true; +} + } // namespace Slic3r diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index e97178abca..154ae540c0 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -420,6 +420,23 @@ struct TextureDisplacementOptions } }; +// Project persistence (see bbs_3mf.cpp). The layer stack and the per-volume options are written to the +// .3mf as JSON rather than through the cereal save()/load() above: those two are positional and +// unversioned, which is right for the undo/redo stack they serve (one session, one binary) but would +// make every future field a project-breaking change. A JSON object tolerates both directions - an +// unknown key is ignored, a missing one keeps the member's default - so old projects keep loading and +// new ones degrade gracefully in older builds. +// +// The texture image itself is *not* in here. It is a binary blob that belongs in the archive as a file +// of its own, exactly as EmbossShape stores its SVG; `path_in_3mf` names that file, and the caller is +// responsible for writing it and for filling `image_data` back in on load. +std::string texture_displacement_layers_to_json(const std::vector &layers, + const TextureDisplacementOptions &options); +// Returns false and leaves both outputs untouched when the text is not valid JSON. +bool texture_displacement_layers_from_json(const std::string &text, + std::vector &layers, + TextureDisplacementOptions &options); + // How much detail a height texture carries: central differences of the grey image, the mean gradient // and the share of texels steeper than 30 grey levels, mapped to how many texels one mesh edge may // span (1 for a hard-edged image, 4 for a smooth one). Cached per image, like the decode. diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 04713d3fd9..609a0571ab 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2112,3 +2112,139 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T CHECK(second <= single * 5 / 4); } + +// The .3mf stores the layer stack as JSON (see texture_displacement_layers_to_json). Two properties +// matter: everything the UV editor and the panel can set has to survive a round trip, and a document +// written by a build that knew fewer fields has to keep loading, with the missing ones left at their +// defaults rather than zeroed. +TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisplacement]") +{ + std::vector layers(2); + TextureDisplacementLayer &a = layers[0]; + a.slot = 0; + a.name = "Bark"; + a.path = "/textures/bark.png"; + a.depth_mm = 1.25f; + a.tiling_scale = 7.5f; + a.rotation_deg = 30.f; + a.offset = Vec2f(0.25f, -0.5f); + a.invert = true; + a.midlevel = 0.125f; + a.smoothing = 0.75f; + a.edge_smoothing = true; + a.edge_smoothing_amount = 0.25f; + a.auto_connect_islands = false; + a.tile_enabled = false; + a.tile_method = TextureTileMethod::Mirror; + a.projection_method = TextureProjectionMethod::LSCM; + a.blend_mode = TextureBlendMode::Subtract; + a.color_enabled = true; + a.lscm_seam_angle_deg = 45.f; + a.island_padding_mm = 0.5f; + a.lscm_seam_edges = { { 1, 2 }, { 3, 5 } }; + a.islands = { TextureIsland{ Vec2f(1.f, 2.f), 15.f, 1.5f }, TextureIsland{} }; + a.island_groups = { 0, 0 }; + a.lscm_uv_overrides = { { -4, Vec2f(0.5f, 0.75f) } }; + + TextureDisplacementLayer &b = layers[1]; + b.slot = 1; + b.projection_method = TextureProjectionMethod::ViewProjected; + b.view_project_right = Vec3f(0.f, 1.f, 0.f); + b.view_project_up = Vec3f(0.f, 0.f, 1.f); + b.view_project_projective = true; + for (size_t i = 0; i < b.view_project_matrix.size(); ++i) + b.view_project_matrix[i] = float(i) + 0.5f; + + TextureDisplacementOptions options; + options.displace_border = false; + options.smooth_enabled = true; + options.smooth_strength = 0.6f; + options.smooth_iterations = 5; + options.pipeline_v2 = false; + options.v2_max_triangles_k = 250; + options.color_mix_mode = ColorMixMode::XYDither; + options.color_despeckle = 4; + + const std::string json = texture_displacement_layers_to_json(layers, options); + + std::vector read_layers; + TextureDisplacementOptions read_options; + REQUIRE(texture_displacement_layers_from_json(json, read_layers, read_options)); + REQUIRE(read_layers.size() == layers.size()); + + const TextureDisplacementLayer &ra = read_layers[0]; + CHECK(ra.name == a.name); + CHECK(ra.path == a.path); + CHECK(ra.depth_mm == Approx(a.depth_mm)); + CHECK(ra.tiling_scale == Approx(a.tiling_scale)); + CHECK(ra.rotation_deg == Approx(a.rotation_deg)); + CHECK(ra.offset.isApprox(a.offset)); + CHECK(ra.invert == a.invert); + CHECK(ra.midlevel == Approx(a.midlevel)); + CHECK(ra.smoothing == Approx(a.smoothing)); + CHECK(ra.edge_smoothing == a.edge_smoothing); + CHECK(ra.edge_smoothing_amount == Approx(a.edge_smoothing_amount)); + CHECK(ra.auto_connect_islands == a.auto_connect_islands); + CHECK(ra.tile_enabled == a.tile_enabled); + CHECK(ra.tile_method == a.tile_method); + CHECK(ra.projection_method == a.projection_method); + CHECK(ra.blend_mode == a.blend_mode); + CHECK(ra.color_enabled == a.color_enabled); + CHECK(ra.lscm_seam_angle_deg == Approx(a.lscm_seam_angle_deg)); + CHECK(ra.island_padding_mm == Approx(a.island_padding_mm)); + CHECK(ra.lscm_seam_edges == a.lscm_seam_edges); + REQUIRE(ra.islands.size() == a.islands.size()); + CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset)); + CHECK(ra.islands[0].rotation_deg == Approx(a.islands[0].rotation_deg)); + CHECK(ra.islands[0].scale == Approx(a.islands[0].scale)); + CHECK(ra.island_groups == a.island_groups); + REQUIRE(ra.lscm_uv_overrides.size() == 1); + CHECK(ra.lscm_uv_overrides[0].first == -4); + CHECK(ra.lscm_uv_overrides[0].second.isApprox(Vec2f(0.5f, 0.75f))); + + const TextureDisplacementLayer &rb = read_layers[1]; + CHECK(rb.projection_method == TextureProjectionMethod::ViewProjected); + CHECK(rb.view_project_right.isApprox(b.view_project_right)); + CHECK(rb.view_project_up.isApprox(b.view_project_up)); + CHECK(rb.view_project_projective); + CHECK(rb.view_project_matrix == b.view_project_matrix); + + CHECK(read_options.displace_border == options.displace_border); + CHECK(read_options.smooth_enabled == options.smooth_enabled); + CHECK(read_options.smooth_strength == Approx(options.smooth_strength)); + CHECK(read_options.smooth_iterations == options.smooth_iterations); + CHECK(read_options.pipeline_v2 == options.pipeline_v2); + CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); + CHECK(read_options.color_mix_mode == options.color_mix_mode); + CHECK(read_options.color_despeckle == options.color_despeckle); +} + +TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]") +{ + // What a build that knew fewer fields would have written: one layer, almost nothing set. + const std::string sparse = R"({"version":1,"layers":[{"slot":2,"name":"Old","depth_mm":0.75}]})"; + + std::vector layers; + TextureDisplacementOptions options; + REQUIRE(texture_displacement_layers_from_json(sparse, layers, options)); + REQUIRE(layers.size() == 1); + + const TextureDisplacementLayer &l = layers[0]; + const TextureDisplacementLayer fresh; + const TextureDisplacementOptions defaults; + CHECK(l.slot == 2); + CHECK(l.name == "Old"); + CHECK(l.depth_mm == Approx(0.75f)); + // Everything absent keeps the struct's own default rather than becoming zero. + CHECK(l.tiling_scale == Approx(fresh.tiling_scale)); + CHECK(l.auto_connect_islands == fresh.auto_connect_islands); + CHECK(l.tile_enabled == fresh.tile_enabled); + CHECK(l.projection_method == fresh.projection_method); + CHECK(l.lscm_seam_angle_deg == Approx(fresh.lscm_seam_angle_deg)); + CHECK(options.color_mix_mode == defaults.color_mix_mode); + CHECK(options.pipeline_v2 == defaults.pipeline_v2); + + std::vector unused_layers; + TextureDisplacementOptions unused_options; + CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options)); +} From fbf4652e12d864411ea6fbc92554cdecb069bbd6 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 9 Oct 2026 09:05:42 +0200 Subject: [PATCH 2/4] Fix the build and clang-tidy failures in the persistence change The round-trip test named a TextureTileMethod enumerator that does not exist (MirroredRepeat, not Mirror) and used a bare Approx, which Catch2 v3 keeps in its own namespace. The rest of the file compares floats with WithinAbs, so these follow it rather than reaching for Catch::Approx. bbs_3mf.cpp reads and writes the layer JSON but picked up the declarations through another header, and the same for boost::to_lower. --- src/libslic3r/Format/bbs_3mf.cpp | 2 ++ tests/libslic3r/test_texture_displacement.cpp | 30 +++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 14e8468cfe..0241692e7b 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -21,6 +21,7 @@ #include "libslic3r/BrimEarsPoint.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/CustomGCode.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r_version.h" #include "bbs_3mf.hpp" @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 0c6aa0e94b..29e6540c0a 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2158,7 +2158,7 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp a.edge_smoothing_amount = 0.25f; a.auto_connect_islands = false; a.tile_enabled = false; - a.tile_method = TextureTileMethod::Mirror; + a.tile_method = TextureTileMethod::MirroredRepeat; a.projection_method = TextureProjectionMethod::LSCM; a.blend_mode = TextureBlendMode::Subtract; a.color_enabled = true; @@ -2198,28 +2198,28 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp const TextureDisplacementLayer &ra = read_layers[0]; CHECK(ra.name == a.name); CHECK(ra.path == a.path); - CHECK(ra.depth_mm == Approx(a.depth_mm)); - CHECK(ra.tiling_scale == Approx(a.tiling_scale)); - CHECK(ra.rotation_deg == Approx(a.rotation_deg)); + CHECK_THAT(ra.depth_mm, WithinAbs(a.depth_mm, 1e-6f)); + CHECK_THAT(ra.tiling_scale, WithinAbs(a.tiling_scale, 1e-6f)); + CHECK_THAT(ra.rotation_deg, WithinAbs(a.rotation_deg, 1e-6f)); CHECK(ra.offset.isApprox(a.offset)); CHECK(ra.invert == a.invert); - CHECK(ra.midlevel == Approx(a.midlevel)); - CHECK(ra.smoothing == Approx(a.smoothing)); + CHECK_THAT(ra.midlevel, WithinAbs(a.midlevel, 1e-6f)); + CHECK_THAT(ra.smoothing, WithinAbs(a.smoothing, 1e-6f)); CHECK(ra.edge_smoothing == a.edge_smoothing); - CHECK(ra.edge_smoothing_amount == Approx(a.edge_smoothing_amount)); + CHECK_THAT(ra.edge_smoothing_amount, WithinAbs(a.edge_smoothing_amount, 1e-6f)); CHECK(ra.auto_connect_islands == a.auto_connect_islands); CHECK(ra.tile_enabled == a.tile_enabled); CHECK(ra.tile_method == a.tile_method); CHECK(ra.projection_method == a.projection_method); CHECK(ra.blend_mode == a.blend_mode); CHECK(ra.color_enabled == a.color_enabled); - CHECK(ra.lscm_seam_angle_deg == Approx(a.lscm_seam_angle_deg)); - CHECK(ra.island_padding_mm == Approx(a.island_padding_mm)); + CHECK_THAT(ra.lscm_seam_angle_deg, WithinAbs(a.lscm_seam_angle_deg, 1e-6f)); + CHECK_THAT(ra.island_padding_mm, WithinAbs(a.island_padding_mm, 1e-6f)); CHECK(ra.lscm_seam_edges == a.lscm_seam_edges); REQUIRE(ra.islands.size() == a.islands.size()); CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset)); - CHECK(ra.islands[0].rotation_deg == Approx(a.islands[0].rotation_deg)); - CHECK(ra.islands[0].scale == Approx(a.islands[0].scale)); + CHECK_THAT(ra.islands[0].rotation_deg, WithinAbs(a.islands[0].rotation_deg, 1e-6f)); + CHECK_THAT(ra.islands[0].scale, WithinAbs(a.islands[0].scale, 1e-6f)); CHECK(ra.island_groups == a.island_groups); REQUIRE(ra.lscm_uv_overrides.size() == 1); CHECK(ra.lscm_uv_overrides[0].first == -4); @@ -2234,7 +2234,7 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp CHECK(read_options.displace_border == options.displace_border); CHECK(read_options.smooth_enabled == options.smooth_enabled); - CHECK(read_options.smooth_strength == Approx(options.smooth_strength)); + CHECK_THAT(read_options.smooth_strength, WithinAbs(options.smooth_strength, 1e-6f)); CHECK(read_options.smooth_iterations == options.smooth_iterations); CHECK(read_options.pipeline_v2 == options.pipeline_v2); CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); @@ -2257,13 +2257,13 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", const TextureDisplacementOptions defaults; CHECK(l.slot == 2); CHECK(l.name == "Old"); - CHECK(l.depth_mm == Approx(0.75f)); + CHECK_THAT(l.depth_mm, WithinAbs(0.75f, 1e-6f)); // Everything absent keeps the struct's own default rather than becoming zero. - CHECK(l.tiling_scale == Approx(fresh.tiling_scale)); + CHECK_THAT(l.tiling_scale, WithinAbs(fresh.tiling_scale, 1e-6f)); CHECK(l.auto_connect_islands == fresh.auto_connect_islands); CHECK(l.tile_enabled == fresh.tile_enabled); CHECK(l.projection_method == fresh.projection_method); - CHECK(l.lscm_seam_angle_deg == Approx(fresh.lscm_seam_angle_deg)); + CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f)); CHECK(options.color_mix_mode == defaults.color_mix_mode); CHECK(options.pipeline_v2 == defaults.pipeline_v2); From 8ecfee6a2160d5a30d622573da62d82a6a10f476 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 9 Oct 2026 09:35:35 +0200 Subject: [PATCH 3/4] Save where the panel was left, and recover a texture the archive lacks The project carried the layer stack but not the user's place in it: a reopened project landed on slot 0 in Standard, whatever they had been editing. The active layer and Standard/Pro now live on the volume, next to the rest of its options, and come back with it. Docked/undocked goes to the application config instead - it describes the user's window, not the model, so opening someone else's project must not move their panel. A layer whose image_data was empty at save time was written with no texture and came back with none. It now falls back to the file the layer came from, which the importer put in the user's own texture library, so a project reopened on the same machine keeps its pixels. The texture is always stored as .png: the importer converts whatever the user picked, so the extension was being taken from a name that no longer described the bytes. Also adds v2_flip_edges to the cereal field list, which the undo stack was silently dropping. --- src/libslic3r/Format/bbs_3mf.cpp | 44 +++++++++++++------ src/libslic3r/TextureDisplacement.cpp | 6 +++ src/libslic3r/TextureDisplacement.hpp | 9 +++- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 30 +++++++++++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 10 ++++- tests/libslic3r/test_texture_displacement.cpp | 7 +++ 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 0241692e7b..d5895603e6 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include #include #include @@ -64,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -3346,16 +3346,34 @@ 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)); + }; + for (TextureDisplacementLayer &layer : layers) { - 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()) { + 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; + } add_error("Missing texture displacement image: " + layer.path_in_3mf); - continue; } - layer.image_data = std::make_shared>(image->second.begin(), - image->second.end()); + load_image_from_disk(layer); } volume.texture_displacement_layers = std::move(layers); volume.texture_displacement_options = options; @@ -8183,12 +8201,10 @@ static void add_texture_displacement(std::stringstream &stream, const ModelVolum layer.path_in_3mf.clear(); if (!layer.image_data || layer.image_data->empty()) continue; - std::string ext = boost::filesystem::path(layer.path).extension().string(); - boost::to_lower(ext); - if (ext != ".png" && ext != ".jpg" && ext != ".jpeg") - ext = ".png"; // the library ships PNG; anything unrecognised is stored under a type a reader expects - const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ext; - // No deflate: PNG and JPEG are already compressed, so a second pass only costs time. + // Always .png: the importer converts whatever the user picked (jpg, bmp, ...) to an 8-bit + // greyscale PNG, so image_data is PNG whatever layer.path happens to be named. + const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ".png"; + // No deflate: a PNG is already compressed, so a second pass only costs time. if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(), MZ_NO_COMPRESSION)) layer.path_in_3mf = path; diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index c89ee713e3..1b910e1006 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -5207,6 +5207,8 @@ std::string texture_displacement_layers_to_json(const std::vector out_layers; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 7c724b900b..c6ebe6c416 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -412,12 +412,19 @@ struct TextureDisplacementOptions // finer than the mesh leaves behind, without eating features that are genuinely a facet wide. int color_despeckle = 2; + // Where the panel was left, rather than anything the bake reads. It lives with the volume because + // that is what it describes: reopening a project should put the user back on the layer they were + // editing, in the mode they were editing it in, not on slot 0 in Standard. + int active_slot = 0; + int panel_mode = 0; // 0 Standard, 1 Pro + template void serialize(Archive &ar) { int mix_mode = int(color_mix_mode); ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border, pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k, - v2_relocate, color_mix_enabled, mix_mode, color_despeckle); + v2_relocate, v2_flip_edges, color_mix_enabled, mix_mode, color_despeckle, + active_slot, panel_mode); color_mix_mode = ColorMixMode(mix_mode); } }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index c467a7768d..3a4a32d00b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3329,6 +3329,7 @@ const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const void GLGizmoTextureDisplacement::update_model_object() { + store_panel_state(); bool updated = false; ModelObject *mo = m_c->selection_info()->model_object(); int idx = -1; @@ -3364,6 +3365,24 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) { wxBusyCursor wait; + // Opening the gizmo puts the panel back where it was left: the layer and the mode come off the + // volume, because they are the user's place in this model's stack, and the dock state off the + // application config, because it is about their window. Only on open - this also runs on an + // undo/redo and on a layer switch, where overriding what the user is doing now would be wrong. + if (first_update) { + if (const ModelVolume *mv = texture_volume(); mv != nullptr) { + const TextureDisplacementOptions &opts = mv->texture_displacement_options; + m_panel_mode = std::clamp(opts.panel_mode, 0, 1); + // Only if that slot still holds a layer: the stack can have been edited by anything that + // does not go through the panel, and a selection pointing at nothing shows an empty panel. + const auto &stack = mv->texture_displacement_layers; + if (std::any_of(stack.begin(), stack.end(), + [&opts](const TextureDisplacementLayer &l) { return l.slot == opts.active_slot; })) + m_active_layer_slot = opts.active_slot; + } + m_undocked = wxGetApp().app_config->get_bool(UNDOCKED_CONFIG_KEY); + } + const ModelObject *mo = m_c->selection_info()->model_object(); m_triangle_selectors.clear(); @@ -3409,6 +3428,14 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) rebuild_preview(); } +void GLGizmoTextureDisplacement::store_panel_state() +{ + if (ModelVolume *mv = texture_volume(); mv != nullptr) { + mv->texture_displacement_options.active_slot = m_active_layer_slot; + mv->texture_displacement_options.panel_mode = m_panel_mode; + } +} + void GLGizmoTextureDisplacement::set_active_layer(int slot) { if (slot == m_active_layer_slot) @@ -3417,6 +3444,7 @@ void GLGizmoTextureDisplacement::set_active_layer(int slot) // reflect - otherwise they would be silently lost. update_model_object(); m_active_layer_slot = slot; + store_panel_state(); update_from_model_object(false); // The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync // instead of leaving it pointing at the previous layer's (now stale) paint patch. @@ -5590,6 +5618,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "not what you wanted and you know why.")); if (clicked && !on) { m_panel_mode = m; + store_panel_state(); if (!pro_mode()) { // Leaving the subdivision preview open would strand a wireframe whose controls just // disappeared, so close it as part of the switch. @@ -5612,6 +5641,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float _L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside " "the toolbar."))) m_undocked = !m_undocked; + wxGetApp().app_config->set_bool(UNDOCKED_CONFIG_KEY, m_undocked); } ImGui::Separator(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 2c6f5bc6c7..32dd66c99c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -206,6 +206,9 @@ private: // Standard is active so what Preview shows is always what Bake will do. Returns true if it actually // changed something, so the caller can invalidate the preview. bool apply_standard_mode_presets(ModelVolume *mv); + // Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file + // can carry it. Cheap and idempotent - called wherever either of the two changes. + void store_panel_state(); // Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace. // The order matters and is the whole reason this is one button - a height map can only move // existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop @@ -904,9 +907,12 @@ private: PatchUnwrap m_uv_editor_unwrap; // When set, the panel is a free-floating window the user can drag anywhere (with a title bar to - // grab), instead of being pinned to the right of the gizmo toolbar. Persisted across gizmo - // open/close within a session, so the choice sticks while working. + // grab), instead of being pinned to the right of the gizmo toolbar. Kept in the application + // config rather than in the project: it describes the user's window, not the model, so opening + // someone else's project must not move their panel. bool m_undocked = false; + // The config key behind m_undocked. + static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked"; // Smooth scrolling for the panel body (everything between the header and the pinned Bake footer). // ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 29e6540c0a..0e617a17f1 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2187,6 +2187,8 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp options.v2_max_triangles_k = 250; options.color_mix_mode = ColorMixMode::XYDither; options.color_despeckle = 4; + options.active_slot = 3; + options.panel_mode = 1; const std::string json = texture_displacement_layers_to_json(layers, options); @@ -2240,6 +2242,9 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); CHECK(read_options.color_mix_mode == options.color_mix_mode); CHECK(read_options.color_despeckle == options.color_despeckle); + // Where the panel was left comes back with the rest, so a reopened project is on the same layer. + CHECK(read_options.active_slot == options.active_slot); + CHECK(read_options.panel_mode == options.panel_mode); } TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]") @@ -2266,6 +2271,8 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f)); CHECK(options.color_mix_mode == defaults.color_mix_mode); CHECK(options.pipeline_v2 == defaults.pipeline_v2); + CHECK(options.active_slot == defaults.active_slot); + CHECK(options.panel_mode == defaults.panel_mode); std::vector unused_layers; TextureDisplacementOptions unused_options; From 3f78959a0c2028922b1452e3bd4a9128046725aa Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 17:47:04 +0800 Subject: [PATCH 4/4] Harden loading and saving of texture displacement in projects A project no longer reads texture files from outside its archive, and a malformed value or layer slot drops only the texture layers instead of failing the whole load. Copies of an object that share a mesh keep their own texture paint when saved, and projects without texture paint no longer use extra memory to load. --- src/libslic3r/Format/bbs_3mf.cpp | 62 +++--- src/libslic3r/TextureDisplacement.cpp | 131 ++++++------ src/libslic3r/TextureDisplacement.hpp | 5 +- tests/libslic3r/test_3mf.cpp | 191 ++++++++++++++++-- tests/libslic3r/test_texture_displacement.cpp | 29 +++ 5 files changed, 311 insertions(+), 107 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 62fe3c91e4..24c2c027e5 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -64,7 +64,6 @@ #include #include #include -#include #include #include #include @@ -856,7 +855,8 @@ 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 like `triangles` but stored only as far as the + // slot's last painted triangle: empty when the slot has no paint, which is nearly every file. std::vector texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS]; // BBS std::vector face_properties; @@ -3355,34 +3355,20 @@ 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 archive is the only source of a layer's pixels. `path` comes from the project file, so loading + // the project must never read the file it names: that would let whoever made the project pick any + // local (or, on Windows, network) file to read, and the next save would embed it in the archive. + // `path` stays a label of where the texture came from; a layer the archive carries no image for + // loads without one, its settings intact, until the user picks a texture again. 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 +4031,11 @@ 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])); + if (const char *paint = bbs_get_attribute_value_charptr(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])) { + std::vector &mask = m_curr_object->geometry.texture_displacement[slot]; + mask.resize(m_curr_object->geometry.triangles.size()); + mask.back() = paint; + } // BBS m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -5401,7 +5390,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { const std::vector &mask = sub_object->geometry.texture_displacement[slot]; if (mask.empty()) - continue; // written by a build without the feature + continue; // no triangle painted on this slot FacetsAnnotation &facets = volume->texture_displacement_facet(slot); facets.reserve(triangles_count); for (size_t i = 0; i < triangles_count && i < mask.size(); ++i) @@ -5917,8 +5906,11 @@ 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])); + if (const char *paint = bbs_get_attribute_value_charptr(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])) { + std::vector &mask = current_object->geometry.texture_displacement[slot]; + mask.resize(current_object->geometry.triangles.size()); + mask.back() = paint; + } // BBS current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -7349,10 +7341,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (iter != m_shared_meshes.end()) { const ModelVolume* shared_volume = iter->second.second; + bool same_texture_paint = true; + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS) && same_texture_paint; ++slot) + same_texture_paint = shared_volume->texture_displacement_facet(slot).equals(volume->texture_displacement_facet(slot)); if ((shared_volume->supported_facets.equals(volume->supported_facets)) && (shared_volume->seam_facets.equals(volume->seam_facets)) && (shared_volume->mmu_segmentation_facets.equals(volume->mmu_segmentation_facets)) - && (shared_volume->fuzzy_skin_facets.equals(volume->fuzzy_skin_facets))) + && (shared_volume->fuzzy_skin_facets.equals(volume->fuzzy_skin_facets)) + && same_texture_paint) { auto data = iter->second.first; const_cast<_BBS_3MF_Exporter *>(this)->m_volume_paths.insert({volume, {data->sub_path, data->volumes_objectID.find(iter->second.second)->second}}); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 80aa313f9b..4e99f97a5a 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -5334,77 +5334,88 @@ bool texture_displacement_layers_from_json(const std::string } 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() skips a value of the wrong type, but the vector, matrix, seam, island and UV-override arrays + // below read their numbers with get<>(), which throws on one; that rejects the document, like text that + // does not parse. + try { + 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); + // The panel and the paint masks are indexed by slot, so a layer without a valid one rejects the + // document rather than reaching them. + if (l.slot < 0 || l.slot >= int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) + return false; + 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, "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, "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, "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); + 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("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("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); - } + 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); + 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())); - } + 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)); + out_layers.push_back(std::move(l)); + } } + } catch (const nlohmann::json::exception &) { + return false; } layers = std::move(out_layers); diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index e86b0c714c..36f66cc9fb 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -424,7 +424,10 @@ struct TextureDisplacementOptions // responsible for writing it and for filling `image_data` back in on load. std::string texture_displacement_layers_to_json(const std::vector &layers, const TextureDisplacementOptions &options); -// Returns false and leaves both outputs untouched when the text is not valid JSON. +// Returns false and leaves both outputs untouched when the text is not a valid JSON object, a number in a +// layer's vector, matrix, seam, island or UV-override array has the wrong type, or a layer's slot is +// missing or outside [0, TEXTURE_DISPLACEMENT_MAX_LAYERS). Any other value of the wrong type or shape +// keeps its default. bool texture_displacement_layers_from_json(const std::string &text, std::vector &layers, TextureDisplacementOptions &options); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f5ac340c58..a59800fafe 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -28,6 +28,7 @@ #include #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleSelector.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include @@ -349,6 +351,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf return replaced; } +// Stores `model` as a one-plate project. +static void store_project(Model& model, const std::string& path, SaveStrategy strategy) +{ + 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 = strategy; + 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 +383,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(model, path, SaveStrategy::Zip64 | SaveStrategy::Silence); } // Loads `path` through the BBS importer into `model`, releasing the plates it returns. The @@ -501,6 +509,163 @@ TEST_CASE("A project with malformed paint data loads without the damaged facet", REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1); } +// Paints `triangles` of `volume` on texture displacement layer slot `slot`. +static void paint_texture_slot(ModelVolume& volume, int slot, std::initializer_list triangles) +{ + TriangleSelector selector(volume.mesh()); + for (const int triangle : triangles) + selector.set_facet(triangle, EnforcerBlockerType::ENFORCER); + REQUIRE(volume.texture_displacement_facet(slot).set(selector)); +} + +// Every triangle's texture paint on every slot, in the form the exporter writes it, so two volumes +// compare equal only when the same triangles are painted on the same slots. +static std::vector texture_paint(const ModelVolume& volume) +{ + std::vector paint; + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + for (size_t i = 0; i < volume.mesh().its.indices.size(); ++i) + paint.push_back(volume.texture_displacement_facet(slot).get_triangle_as_string(int(i))); + return paint; +} + +// A cube with one texture displacement layer in slot 2 and texture paint on gapped triangles of two +// slots. The layer's image bytes need not decode: the archive stores them as they are. +static ModelVolume& add_texture_displaced_cube(Model& model) +{ + ModelObject* object = model.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + + TextureDisplacementLayer layer; + layer.slot = 2; + layer.name = "Bark"; + layer.depth_mm = 1.25f; + layer.image_data = std::make_shared>(std::vector{ 0x89, 'P', 'N', 'G', 1, 2, 3 }); + volume->texture_displacement_layers.push_back(layer); + volume->texture_displacement_options.active_slot = 2; + + paint_texture_slot(*volume, 2, { 3, 7 }); + paint_texture_slot(*volume, 5, { 10 }); + return *volume; +} + +TEST_CASE("Texture displacement layers, textures and paint survive a project round trip", "[3mf]") +{ + // A single model file, and one file per object (read by a separate importer). + const SaveStrategy strategy = GENERATE(SaveStrategy::Zip64 | SaveStrategy::Silence, + SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel); + INFO("save strategy " << int(strategy)); + + ScopedTemporaryFile temp(".3mf"); + Model source; + const ModelVolume& painted = add_texture_displaced_cube(source); + const std::vector paint = texture_paint(painted); + REQUIRE(std::count_if(paint.begin(), paint.end(), [](const std::string& s) { return !s.empty(); }) == 3); + store_project(source, temp.string(), strategy); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 1); + const ModelVolume& volume = *model.objects.front()->volumes.front(); + + REQUIRE(volume.texture_displacement_layers.size() == 1); + const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front(); + CHECK(layer.slot == 2); + CHECK(layer.name == "Bark"); + CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(1.25f, 1e-6f)); + REQUIRE(layer.image_data); + CHECK(*layer.image_data == *painted.texture_displacement_layers.front().image_data); + CHECK(volume.texture_displacement_options.active_slot == 2); + CHECK(texture_paint(volume) == paint); +} + +TEST_CASE("Volumes sharing a mesh keep their own texture displacement paint", "[3mf]") +{ + Model source; + ModelObject* first_object = source.add_object(); + ModelVolume* first = first_object->add_volume(make_cube(10., 10., 10.)); + first_object->add_instance(); + ModelVolume* second = source.add_object(*first_object)->volumes.front(); + REQUIRE(second->mesh_ptr() == first->mesh_ptr()); + paint_texture_slot(*first, 0, { 3 }); + paint_texture_slot(*second, 0, { 7 }); + + // What a project save from the app uses: one file per object, an identical mesh written once. + ScopedTemporaryFile temp(".3mf"); + store_project(source, temp.string(), + SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::ShareMesh); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 2); + CHECK(texture_paint(*model.objects[0]->volumes.front()) == texture_paint(*first)); + CHECK(texture_paint(*model.objects[1]->volumes.front()) == texture_paint(*second)); +} + +TEST_CASE("A texture displacement layer never loads its image from outside the project", "[3mf]") +{ + // A file the layer names, on the machine opening the project, that the project did not carry. + ScopedTemporaryFile texture(".png"); + { + boost::nowide::ofstream file(texture.string(), std::ios::binary); + file << "not part of the project"; + } + REQUIRE(boost::filesystem::file_size(texture.path()) > 0); + + Model source; + ModelObject* object = source.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = "Elsewhere"; + layer.path = texture.string(); + layer.depth_mm = 0.75f; + volume->texture_displacement_layers.push_back(layer); + + ScopedTemporaryFile temp(".3mf"); + store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 1); + const std::vector& layers = model.objects.front()->volumes.front()->texture_displacement_layers; + REQUIRE(layers.size() == 1); + CHECK(layers.front().name == "Elsewhere"); + CHECK_THAT(layers.front().depth_mm, Catch::Matchers::WithinAbs(0.75f, 1e-6f)); + CHECK_FALSE(layers.front().image_data); +} + +TEST_CASE("A project with a malformed texture displacement value still loads", "[3mf][Regression]") +{ + const auto [from, to] = GENERATE(table({ + { "\"islands\":[]", "\"islands\":[[null,0,0,1]]" }, // a number that is null + { "\"slot\":2", "\"slot\":8" }, // a slot past the last + })); + INFO(from << " -> " << to); + + ScopedTemporaryFile temp(".3mf"); + Model source; + const ModelVolume& painted = add_texture_displaced_cube(source); + store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence); + REQUIRE(replace_in_3mf_entry(temp.string(), ".json", from, to)); + + 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); + const ModelVolume& volume = *model.objects.front()->volumes.front(); + // The layer stack is dropped as a whole; the paint, stored apart from it, still loads. + CHECK(volume.texture_displacement_layers.empty()); + CHECK(texture_paint(volume) == texture_paint(painted)); +} + // The recipe lives only in the BBS-native backend, because that is the only one that runs: // store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for // files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves: diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index fefc38ac33..f71c839c42 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include "libslic3r/AABBMesh.hpp" @@ -2300,6 +2301,34 @@ 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)); } +TEST_CASE("Texture displacement JSON with a wrong-typed element or an out-of-range slot is rejected", "[TextureDisplacement]") +{ + const std::string layer = GENERATE(as{}, + R"({"slot":0,"offset":["x",0]})", + R"({"slot":0,"view_project_right":[null,0,0]})", + R"({"slot":0,"view_project_matrix":[0,0,0,0,0,0,0,0,0,0,0,"x"]})", + R"({"slot":0,"lscm_seam_edges":[1,null]})", + R"({"slot":0,"islands":[[0,0,"x",1]]})", + R"({"slot":0,"lscm_uv_overrides":[[null,0,0]]})", + R"({"slot":8})", + R"({"slot":-1})", + R"({"slot":"x"})", + R"({"name":"No slot"})"); + INFO(layer); + + std::vector layers(1); + layers.front().name = "Kept"; + TextureDisplacementOptions options; + options.color_mix_count = 7; + + bool parsed = true; + REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(R"({"version":1,"layers":[)" + layer + "]}", layers, options)); + CHECK_FALSE(parsed); + REQUIRE(layers.size() == 1); + CHECK(layers.front().name == "Kept"); + CHECK(options.color_mix_count == 7); +} + TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]") { TextureDisplacementLayer gray;