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)); +}