diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 545cb8c05f..24c2c027e5 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -22,6 +22,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" @@ -354,6 +355,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"; @@ -847,6 +855,9 @@ 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, 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; @@ -867,6 +878,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(); } }; @@ -1200,6 +1213,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; @@ -2073,6 +2091,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 @@ -3310,6 +3335,45 @@ 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; + } + // 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()) + 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'); @@ -3966,6 +4030,12 @@ 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) + 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)); } @@ -5317,6 +5387,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; // 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) + 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); @@ -5332,6 +5414,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 @@ -5496,12 +5580,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 @@ -5805,6 +5905,12 @@ 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) + 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)); } @@ -6808,6 +6914,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << " \n"; stream << " \n"; stream << " \n"; + stream << " \n"; + stream << " \n"; + stream << " \n"; stream << " \n"; stream << ""; @@ -7232,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}}); @@ -7651,6 +7764,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(); @@ -8100,7 +8227,42 @@ 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; + // 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; + } + + 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 @@ -8211,6 +8373,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 ec9d9a2e11..4e99f97a5a 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -19,6 +19,7 @@ #include #include #include +#include "nlohmann/json.hpp" #include #include #include @@ -5166,5 +5167,260 @@ 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_despeckle"] = options.color_despeckle; + opt["color_mix_count"] = options.color_mix_count; + opt["active_slot"] = options.active_slot; + opt["panel_mode"] = options.panel_mode; + + 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); + read(opt, "color_mix_count", out_options.color_mix_count); + read(opt, "active_slot", out_options.active_slot); + read(opt, "panel_mode", out_options.panel_mode); + out_options.active_slot = std::clamp(out_options.active_slot, 0, int(TEXTURE_DISPLACEMENT_MAX_LAYERS) - 1); + out_options.panel_mode = std::clamp(out_options.panel_mode, 0, 1); + } + + std::vector out_layers; + // 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, "edge_smoothing", l.edge_smoothing); + read(j, "edge_smoothing_amount", l.edge_smoothing_amount); + read(j, "auto_connect_islands", l.auto_connect_islands); + + read(j, "tile_enabled", l.tile_enabled); + read(j, "color_enabled", l.color_enabled); + int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); + read_enum(j, "tile_method", tile_method); + read_enum(j, "projection_method", projection); + read_enum(j, "blend_mode", blend); + l.tile_method = TextureTileMethod(tile_method); + l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); + l.blend_mode = TextureBlendMode(blend); + + read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); + read(j, "island_padding_mm", l.island_padding_mm); + + if (const auto v = j.find("view_project_right"); v != j.end()) + l.view_project_right = vec3_from_json(*v, l.view_project_right); + if (const auto v = j.find("view_project_up"); v != j.end()) + l.view_project_up = vec3_from_json(*v, l.view_project_up); + read(j, "view_project_projective", l.view_project_projective); + if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) + for (size_t i = 0; i < 12; ++i) + l.view_project_matrix[i] = (*v)[i].get(); + + if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) + for (size_t i = 0; i + 1 < v->size(); i += 2) + l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); + + if (const auto v = j.find("islands"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 4) + continue; + TextureIsland island; + island.offset = Vec2f(e[0].get(), e[1].get()); + island.rotation_deg = e[2].get(); + island.scale = e[3].get(); + l.islands.push_back(island); + } + + read(j, "island_groups", l.island_groups); + + if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 3) + continue; + l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); + } + + out_layers.push_back(std::move(l)); + } + } + } catch (const nlohmann::json::exception &) { + return false; + } + + 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 d61739c7d8..36f66cc9fb 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -397,14 +397,41 @@ 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) { 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, color_despeckle, color_mix_count); + v2_relocate, v2_flip_edges, color_mix_enabled, color_despeckle, color_mix_count, + active_slot, panel_mode); } }; +// 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 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); + // 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/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..a9e7452272 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3558,6 +3558,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; @@ -3593,6 +3594,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(); // The base keeps the last mesh a fill tool hovered, and render_painter_gizmo() reads it as a hover that is @@ -3641,6 +3660,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) @@ -3649,6 +3676,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. @@ -5986,6 +6014,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. @@ -6008,6 +6037,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 48e0ab72f6..5fe6d70965 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -262,6 +262,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 @@ -971,9 +974,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_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 485f4f2a0f..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" @@ -2157,6 +2158,177 @@ 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::MirroredRepeat; + 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_count = 3; + options.color_despeckle = 4; + options.active_slot = 3; + options.panel_mode = 1; + + 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_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_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_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_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_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); + 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_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); + CHECK(read_options.color_mix_count == options.color_mix_count); + 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]") +{ + // 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_THAT(l.depth_mm, WithinAbs(0.75f, 1e-6f)); + // Everything absent keeps the struct's own default rather than becoming zero. + 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_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f)); + CHECK(options.color_mix_count == defaults.color_mix_count); + 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; + 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;