diff --git a/cmake/modules/ClangClShowIncludes.cmake b/cmake/modules/ClangClShowIncludes.cmake index 91b9a18069..fcc48fe3f7 100644 --- a/cmake/modules/ClangClShowIncludes.cmake +++ b/cmake/modules/ClangClShowIncludes.cmake @@ -1,10 +1,18 @@ # ccache does not parse the -clang: arguments CMake uses for clang-cl's gcc-style # depfile, so a cache hit writes the object and no depfile, and Ninja then records # no headers for that object. ccache reproduces /showIncludes output on a hit. +# +# Clang also records the modification time of every input in a precompiled header +# and rejects the header once one differs. ccache caches the compile that creates +# the header, so a tree that regenerates cmake_pch.cxx gets back a header stamped +# with the old file's time, and every compile that uses it fails. The build system +# already rebuilds the header when an input changes. This covers the dependencies, +# such as wxWidgets; PrecompiledHeader.cmake does the same for OrcaSlicer's targets. foreach (_lang C CXX) if (CMAKE_${_lang}_COMPILER_ID STREQUAL "Clang" AND CMAKE_${_lang}_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") set(CMAKE_DEPFILE_FLAGS_${_lang} "/showIncludes") set(CMAKE_${_lang}_DEPFILE_FORMAT msvc) + list(APPEND CMAKE_${_lang}_COMPILE_OPTIONS_CREATE_PCH -Xclang -fno-pch-timestamp) endif () endforeach () diff --git a/resources/images/score_star_outline.svg b/resources/images/score_star_outline.svg new file mode 100644 index 0000000000..eb52be9e82 --- /dev/null +++ b/resources/images/score_star_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json index 4b6496df31..dcb39ee957 100644 --- a/resources/profiles/Qidi.json +++ b/resources/profiles/Qidi.json @@ -1,6 +1,6 @@ { "name": "Qidi", - "version": "02.04.00.16", + "version": "02.04.00.17", "force_update": "0", "description": "Qidi configurations", "machine_model_list": [ diff --git a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json index bd2398ae7a..30db0fc68b 100644 --- a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json @@ -83,7 +83,7 @@ "extruder_clearance_height_to_lid": "120", "layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", - "machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", + "machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", "thumbnails_format": "PNG", "default_filament_profile": [ "Generic PLA @Qidi Q1 Pro 0.4 nozzle" diff --git a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json index a41e2b6a11..968f7f63a9 100644 --- a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json @@ -100,7 +100,7 @@ "default_filament_profile": [ "Generic PLA @Qidi" ], - "machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", + "machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", "machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n", "scan_first_layer": "0" } diff --git a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json index 9796856379..8fe450c3c6 100644 --- a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json +++ b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json @@ -24,7 +24,7 @@ "210x210/COLPIC", "110x110/PNG" ], - "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", + "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", "scan_first_layer": "0" } diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 8a22201c51..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; @@ -1729,8 +1747,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) //plate->pattern_file = it->second->pattern_file; plate->no_light_thumbnail_file = it->second->no_light_thumbnail_file; plate->top_file = it->second->top_file; - plate->pick_file = it->second->pick_file.empty(); - plate->pattern_bbox_file = it->second->pattern_bbox_file.empty(); + plate->pick_file = it->second->pick_file; + plate->pattern_bbox_file = it->second->pattern_bbox_file; plate->config = it->second->config; if (!plate->thumbnail_file.empty()) @@ -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/GCode.cpp b/src/libslic3r/GCode.cpp index cab75d5034..ec7ef83a3d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3295,7 +3295,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato m_is_role_based_fan_on.fill(false); m_role_based_fan_marker_layer.fill(-1); - m_fan_mover.release(); + m_fan_mover.reset(); m_ordering_cache.clear(); // Belt printer: initialize belt-specific writer via virtual hook. diff --git a/src/libslic3r/JumpPointSearch.cpp b/src/libslic3r/JumpPointSearch.cpp deleted file mode 100644 index 9eb18017fb..0000000000 --- a/src/libslic3r/JumpPointSearch.cpp +++ /dev/null @@ -1,349 +0,0 @@ -#include "JumpPointSearch.hpp" -#include "BoundingBox.hpp" -#include "ExPolygon.hpp" -#include "Point.hpp" -#include "libslic3r/AStar.hpp" -#include "libslic3r/KDTreeIndirect.hpp" -#include "libslic3r/Polygon.hpp" -#include "libslic3r/Polyline.hpp" -#include "libslic3r/libslic3r.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -//#define DEBUG_FILES -#ifdef DEBUG_FILES -#include "libslic3r/SVG.hpp" -#endif - -namespace Slic3r { - -// execute fn for each pixel on the line. If fn returns false, terminate the iteration -template void dda(coord_t x0, coord_t y0, coord_t x1, coord_t y1, const PointFn &fn) -{ - coord_t dx = abs(x1 - x0); - coord_t dy = abs(y1 - y0); - coord_t x = x0; - coord_t y = y0; - coord_t n = 1 + dx + dy; - coord_t x_inc = (x1 > x0) ? 1 : -1; - coord_t y_inc = (y1 > y0) ? 1 : -1; - coord_t error = dx - dy; - dx *= 2; - dy *= 2; - - for (; n > 0; --n) { - if (!fn(x, y)) return; - - if (error > 0) { - x += x_inc; - error -= dy; - } else { - y += y_inc; - error += dx; - } - } -} - -// will draw the line twice, second time with and offset of 1 in the direction of normal -// may call the fn on the same coordiantes multiple times! -template void double_dda_with_offset(coord_t x0, coord_t y0, coord_t x1, coord_t y1, const PointFn &fn) -{ - Vec2d normal = Point{y1 - y0, x1 - x0}.cast().normalized(); - normal.x() = ceil(normal.x()); - normal.y() = ceil(normal.y()); - Point start_offset = Point(x0, y0) + (normal).cast(); - Point end_offset = Point(x1, y1) + (normal).cast(); - - dda(x0, y0, x1, y1, fn); - dda(start_offset.x(), start_offset.y(), end_offset.x(), end_offset.y(), fn); -} - -template class JPSTracer -{ -public: - // Use incoming_dir [0,0] for starting points, so that all directions are checked from that point - struct Node - { - CellPositionType position; - CellPositionType incoming_dir; - }; - - JPSTracer(CellPositionType target, CellQueryFn is_passable) : target(target), is_passable(is_passable) {} - -private: - CellPositionType target; - CellQueryFn is_passable; // should return boolean whether the cell is passable or not - - CellPositionType find_jump_point(CellPositionType start, CellPositionType forward_dir) const - { - CellPositionType next = start + forward_dir; - while (next != target && is_passable(next) && !(is_jump_point(next, forward_dir))) { next = next + forward_dir; } - - if (is_passable(next)) { - return next; - } else { - return start; - } - } - - bool is_jump_point(CellPositionType pos, CellPositionType forward_dir) const - { - if (abs(forward_dir.x()) + abs(forward_dir.y()) == 2) { - // diagonal - CellPositionType horizontal_check_dir = CellPositionType{forward_dir.x(), 0}; - CellPositionType vertical_check_dir = CellPositionType{0, forward_dir.y()}; - - if (!is_passable(pos - horizontal_check_dir) && is_passable(pos + forward_dir - 2 * horizontal_check_dir)) { return true; } - - if (!is_passable(pos - vertical_check_dir) && is_passable(pos + forward_dir - 2 * vertical_check_dir)) { return true; } - - if (find_jump_point(pos, horizontal_check_dir) != pos) { return true; } - - if (find_jump_point(pos, vertical_check_dir) != pos) { return true; } - - return false; - } else { // horizontal or vertical - CellPositionType side_dir = CellPositionType(forward_dir.y(), forward_dir.x()); - - if (!is_passable(pos + side_dir) && is_passable(pos + forward_dir + side_dir)) { return true; } - - if (!is_passable(pos - side_dir) && is_passable(pos + forward_dir - side_dir)) { return true; } - - return false; - } - } - -public: - template void foreach_reachable(const Node &from, Fn &&fn) const - { - const CellPositionType & pos = from.position; - const CellPositionType & forward_dir = from.incoming_dir; - std::vector dirs_to_check{}; - - if (abs(forward_dir.x()) + abs(forward_dir.y()) == 0) { // special case for starting point - dirs_to_check = all_directions; - } else if (abs(forward_dir.x()) + abs(forward_dir.y()) == 2) { - // diagonal - CellPositionType horizontal_check_dir = CellPositionType{forward_dir.x(), 0}; - CellPositionType vertical_check_dir = CellPositionType{0, forward_dir.y()}; - - if (!is_passable(pos - horizontal_check_dir) && is_passable(pos + forward_dir - 2 * horizontal_check_dir)) { - dirs_to_check.push_back(forward_dir - 2 * horizontal_check_dir); - } - - if (!is_passable(pos - vertical_check_dir) && is_passable(pos + forward_dir - 2 * vertical_check_dir)) { - dirs_to_check.push_back(forward_dir - 2 * vertical_check_dir); - } - - dirs_to_check.push_back(horizontal_check_dir); - dirs_to_check.push_back(vertical_check_dir); - dirs_to_check.push_back(forward_dir); - - } else { // horizontal or vertical - CellPositionType side_dir = CellPositionType(forward_dir.y(), forward_dir.x()); - - if (!is_passable(pos + side_dir) && is_passable(pos + forward_dir + side_dir)) { dirs_to_check.push_back(forward_dir + side_dir); } - - if (!is_passable(pos - side_dir) && is_passable(pos + forward_dir - side_dir)) { dirs_to_check.push_back(forward_dir - side_dir); } - dirs_to_check.push_back(forward_dir); - } - - for (const CellPositionType &dir : dirs_to_check) { - CellPositionType jp = find_jump_point(pos, dir); - if (jp != pos) fn(Node{jp, dir}); - } - } - - float distance(Node a, Node b) const { return (a.position - b.position).template cast().norm(); } - - float goal_heuristic(Node n) const { return n.position == target ? -1.f : (target - n.position).template cast().norm(); } - - size_t unique_id(Node n) const { return (static_cast(uint16_t(n.position.x())) << 16) + static_cast(uint16_t(n.position.y())); } - - const std::vector all_directions{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}; -}; - -void JPSPathFinder::clear() -{ - inpassable.clear(); - max_search_box.max = Pixel(std::numeric_limits::min(), std::numeric_limits::min()); - max_search_box.min = Pixel(std::numeric_limits::max(), std::numeric_limits::max()); - add_obstacles(bed_shape); -} - -void JPSPathFinder::add_obstacles(const Lines &obstacles) -{ - auto store_obstacle = [&](coord_t x, coord_t y) { - max_search_box.max.x() = std::max(max_search_box.max.x(), x); - max_search_box.max.y() = std::max(max_search_box.max.y(), y); - max_search_box.min.x() = std::min(max_search_box.min.x(), x); - max_search_box.min.y() = std::min(max_search_box.min.y(), y); - inpassable.insert(Pixel{x, y}); - return true; - }; - - for (const Line &l : obstacles) { - Pixel start = pixelize(l.a); - Pixel end = pixelize(l.b); - double_dda_with_offset(start.x(), start.y(), end.x(), end.y(), store_obstacle); - } -} - -Polyline JPSPathFinder::find_path(const Point &p0, const Point &p1) -{ - Pixel start = pixelize(p0); - Pixel end = pixelize(p1); - if (inpassable.empty() || (start - end).cast().norm() < 3.0) { return Polyline{p0, p1}; } - - if (inpassable.find(start) != inpassable.end()) { - dda(start.x(), start.y(), end.x(), end.y(), [&](coord_t x, coord_t y) { - if (inpassable.find(Pixel(x, y)) == inpassable.end() || start == end) { // new start not found yet, and xy passable - start = Pixel(x, y); - return false; - } - return true; - }); - } - - if (inpassable.find(end) != inpassable.end()) { - dda(end.x(), end.y(), start.x(), start.y(), [&](coord_t x, coord_t y) { - if (inpassable.find(Pixel(x, y)) == inpassable.end() || start == end) { // new start not found yet, and xy passable - end = Pixel(x, y); - return false; - } - return true; - }); - } - - BoundingBox search_box = max_search_box; - search_box.max -= Pixel(1, 1); - search_box.min += Pixel(1, 1); - - BoundingBox bounding_square(Points{start, end}); - bounding_square.max += Pixel(5, 5); - bounding_square.min -= Pixel(5, 5); - coord_t bounding_square_size = 2 * std::max(bounding_square.size().x(), bounding_square.size().y()); - bounding_square.max.x() += (bounding_square_size - bounding_square.size().x()) / 2; - bounding_square.min.x() -= (bounding_square_size - bounding_square.size().x()) / 2; - bounding_square.max.y() += (bounding_square_size - bounding_square.size().y()) / 2; - bounding_square.min.y() -= (bounding_square_size - bounding_square.size().y()) / 2; - - // Intersection - limit the search box to a square area around the start and end, to fasten the path searching - search_box.max = search_box.max.cwiseMin(bounding_square.max); - search_box.min = search_box.min.cwiseMax(bounding_square.min); - - auto cell_query = [&](Pixel pixel) { return search_box.contains(pixel) && (pixel == start || pixel == end || inpassable.find(pixel) == inpassable.end()); }; - - JPSTracer tracer(end, cell_query); - using QNode = astar::QNode>; - - std::unordered_map astar_cache{}; - std::vector> out_path; - std::vector out_nodes; - - if (!astar::search_route(tracer, {start, {0, 0}}, std::back_inserter(out_nodes), astar_cache)) { - // path not found - just reconstruct the best path from astar cache. - // Note that astar_cache is NOT empty - at least the starting point should always be there - auto coordiante_func = [&astar_cache](size_t idx, size_t dim) { return float(astar_cache[idx].node.position[dim]); }; - std::vector keys; - keys.reserve(astar_cache.size()); - for (const auto &pair : astar_cache) { keys.push_back(pair.first); } - KDTreeIndirect<2, float, decltype(coordiante_func)> kd_tree(coordiante_func, keys); - size_t closest_qnode = find_closest_point(kd_tree, end.cast()); - - out_path.push_back(end); - while (closest_qnode != astar::Unassigned) { - out_path.push_back(astar_cache[closest_qnode].node.position); - closest_qnode = astar_cache[closest_qnode].parent; - } - } else { - for (const auto &node : out_nodes) { out_path.push_back(node.position); } - out_path.push_back(start); - } - -#ifdef DEBUG_FILES - auto scaled_points = [](const Points &ps) { - Points r; - for (const Point &p : ps) { r.push_back(Point::new_scale(p.x(), p.y())); } - return r; - }; - auto scaled_point = [](const Point &p) { return Point::new_scale(p.x(), p.y()); }; - ::Slic3r::SVG svg(debug_out_path(("path_jps" + std::to_string(print_z) + "_" + std::to_string(rand() % 1000)).c_str()).c_str(), - BoundingBox(scaled_point(search_box.min), scaled_point(search_box.max))); - for (const auto &p : inpassable) { svg.draw(scaled_point(p), "black", scale_(0.4)); } - for (const auto &qn : astar_cache) { svg.draw(scaled_point(qn.second.node.position), "blue", scale_(0.3)); } - svg.draw(Polyline(scaled_points(out_path)), "yellow", scale_(0.25)); - svg.draw(scaled_point(end), "purple", scale_(0.4)); - svg.draw(scaled_point(start), "green", scale_(0.4)); -#endif - - std::vector> tmp_path; - tmp_path.reserve(out_path.size()); - // Some path found, reverse and remove points that do not change direction - std::reverse(out_path.begin(), out_path.end()); - { - tmp_path.push_back(out_path.front()); // first point - for (size_t i = 1; i < out_path.size() - 1; i++) { - if ((out_path[i] - out_path[i - 1]).cast().normalized() != (out_path[i + 1] - out_path[i]).cast().normalized()) { tmp_path.push_back(out_path[i]); } - } - tmp_path.push_back(out_path.back()); // last_point - out_path = tmp_path; - } - -#ifdef DEBUG_FILES - svg.draw(Polyline(scaled_points(out_path)), "orange", scale_(0.20)); -#endif - - tmp_path.clear(); - // remove redundant jump points - there are points that change direction but are not needed - this inefficiency arises from the - // usage of grid search The removal alg tries to find the longest Px Px+k path without obstacles. If Px Px+k+1 is blocked, it will - // insert the Px+k point to result and continue search from Px+k - { - tmp_path.push_back(out_path.front()); // first point - size_t index_of_last_stored_point = 0; - for (size_t i = 1; i < out_path.size(); i++) { - if (i - index_of_last_stored_point < 2) continue; - bool passable = true; - auto store_obstacle = [&](coord_t x, coord_t y) { - if (Pixel(x, y) != start && Pixel(x, y) != end && inpassable.find(Pixel(x, y)) != inpassable.end()) { - passable = false; - return false; - } - return true; - }; - dda(tmp_path.back().x(), tmp_path.back().y(), out_path[i].x(), out_path[i].y(), store_obstacle); - if (!passable) { - tmp_path.push_back(out_path[i - 1]); - index_of_last_stored_point = i - 1; - } - } - tmp_path.push_back(out_path.back()); // last_point - out_path = tmp_path; - } - -#ifdef DEBUG_FILES - svg.draw(Polyline(scaled_points(out_path)), "red", scale_(0.15)); - svg.Close(); -#endif - - // before returing the path, transform it from pixels back to points. - // Also replace the first and last pixel by input points so that result path patches input params exactly. - for (Pixel &p : out_path) { p = unpixelize(p); } - out_path.front() = p0; - out_path.back() = p1; - - return Polyline(out_path); -} - -} // namespace Slic3r diff --git a/src/libslic3r/JumpPointSearch.hpp b/src/libslic3r/JumpPointSearch.hpp deleted file mode 100644 index b5fbf6aaf0..0000000000 --- a/src/libslic3r/JumpPointSearch.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once -#include "Line.hpp" -#ifndef SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ -#define SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ - -#include "BoundingBox.hpp" -#include "Polygon.hpp" -#include "libslic3r/Layer.hpp" -#include "libslic3r/Point.hpp" -#include "libslic3r/Polyline.hpp" -#include "libslic3r/libslic3r.h" -#include -#include - -namespace Slic3r { - -class JPSPathFinder -{ - using Pixel = Point; - std::unordered_set inpassable; - coordf_t print_z; - BoundingBox max_search_box; - Lines bed_shape; - - const coord_t resolution = scaled(1.5); - Pixel pixelize(const Point &p) { return p / resolution; } - Point unpixelize(const Pixel &p) { return p * resolution; } - -public: - JPSPathFinder() = default; - void init_bed_shape(const Points &bed_shape) { this->bed_shape = (to_lines(Polygon{bed_shape})); }; - void clear(); - void add_obstacles(const Lines &obstacles); - Polyline find_path(const Point &start, const Point &end); -}; - -} // namespace Slic3r - -#endif /* SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ */ diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 6038ac2ac3..583a98b3ca 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 @@ -5373,5 +5374,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 06c655b070..a859aa2f8e 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -413,14 +413,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/libslic3r/TriangleMeshDeal.hpp b/src/libslic3r/TriangleMeshDeal.hpp index b1da431229..89d613bb85 100644 --- a/src/libslic3r/TriangleMeshDeal.hpp +++ b/src/libslic3r/TriangleMeshDeal.hpp @@ -1,5 +1,4 @@ -#ifndef libslic3r_Timer_hpp_ -#define libslic3r_Timer_hpp_ +#pragma once #include "TriangleMesh.hpp" @@ -10,5 +9,3 @@ public: static TriangleMesh smooth_triangle_mesh(const TriangleMesh &mesh,bool& ok); }; } // namespace Slic3r - -#endif // libslic3r_Timer_hpp_ diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 24ebe9a337..d9d30573fc 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -109,7 +109,6 @@ extern std::string format_memsize_MB(size_t n); extern std::string format_memsize(size_t bytes, unsigned int decimals = 1); // Return string to be added to the boost::log output to inform about the current process memory allocation. // The string is non-empty if the loglevel >= info (3) or ignore_loglevel==true. -// Latter is used to get the memory info from SysInfoDialog. extern std::string log_memory_info(bool ignore_loglevel = false); extern void disable_multi_threading(); // Returns the size of physical memory (RAM) in bytes. diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index cd9ecf100d..e8cc1759d5 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1744,9 +1744,7 @@ std::string format_diameter_to_str(double diameter, int precision) return oss.str(); } -// Returns platform-specific string to be used as log output or parsed in SysInfoDialog. -// The latter parses the string with (semi)colons as separators, it should look about as -// "desc1: value1; desc2: value2" or similar (spaces should not matter). +// Returns platform-specific string to be used as log output. std::string log_memory_info(bool ignore_loglevel) { std::string out; diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 65c8384ef2..dd13fd5353 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -416,8 +416,6 @@ set(SLIC3R_GUI_SOURCES GUI/MultiSendMachineModel.hpp GUI/MultiTaskManagerPage.cpp GUI/MultiTaskManagerPage.hpp - GUI/MultiTaskModel.cpp - GUI/MultiTaskModel.hpp GUI/NetworkTestDialog.cpp GUI/NetworkTestDialog.hpp GUI/Notebook.cpp @@ -802,7 +800,6 @@ set(SLIC3R_GUI_SOURCES Utils/PrintHost.hpp Utils/Process.cpp Utils/Process.hpp - Utils/Profile.hpp Utils/RaycastManager.cpp Utils/RaycastManager.hpp Utils/Repetier.cpp diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 903524caaa..41047dffd9 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -16,6 +16,7 @@ #include #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/Button.hpp" +#include "slic3r/GUI/Widgets/DropDown.hpp" #include "slic3r/GUI/Widgets/Label.hpp" #include #include "slic3r/GUI/wxExtensions.hpp" @@ -172,7 +173,26 @@ void AMSMaterialsSetting::create() Bind(wxEVT_PAINT, &AMSMaterialsSetting::paintEvent, this); Bind(EVT_SELECTED_COLOR, &AMSMaterialsSetting::on_picker_color, this); - m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); + + // Favorites toggled in the open list are regrouped just before it opens again, so rows never move + // under the cursor. These handlers run ahead of ComboBox's own, which then measure and show the + // rebuilt rows; wxEVT_COMBOBOX_DROPDOWN would be too late, as ComboBox sends it after showing the list. + auto regroup_favorites = [this]() { + if (!m_favorites_regroup || m_comboBox_filament->is_drop_down()) + return; + const FilamentInfos* info = selected_filament_info(); + const std::string filament_id = info ? info->filament_id : std::string(); + rebuild_filament_dropdown_items(filament_id); + }; + m_comboBox_filament->Bind(wxEVT_LEFT_DOWN, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_LEFT_DCLICK, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_KEY_DOWN, [regroup_favorites](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_RETURN || e.GetKeyCode() == WXK_SPACE) // the keys that open the list + regroup_favorites(); + e.Skip(); + }); m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -191,11 +211,9 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0); -#ifdef __APPLE__ - m_comboBox_filament = new wxComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#else m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#endif + m_star_filled = ScalableBitmap(this, "score_star_light", 16); + m_star_empty = ScalableBitmap(this, "score_star_outline", 16); m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); @@ -420,6 +438,7 @@ void AMSMaterialsSetting::paintEvent(wxPaintEvent &evt) AMSMaterialsSetting::~AMSMaterialsSetting() { m_comboBox_filament->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Unbind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); m_comboBox_cali_result->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -531,10 +550,9 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) { std::string selected_ams_id; PresetBundle *preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { + const FilamentInfos* selected_info = selected_filament_info(); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) { selected_ams_id = it->filament_id; break; } @@ -682,12 +700,13 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event) ams_setting_id = ""; // the combobox item - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; + const FilamentInfos* selected_info = selected_filament_info(); + const std::string filament_id = selected_info ? selected_info->filament_id : std::string(); // check filament info (rack-gated per-nozzle blacklist evaluation) PresetBundle* preset_bundle = wxGetApp().preset_bundle; const auto& fila_check_res = sCheckFilamentInfo(preset_bundle, obj, ams_id, slot_id, - filament_item.filament_id, ams_filament_id, ams_setting_id); + filament_id, ams_filament_id, ams_setting_id); if (const auto& prohibit_items = fila_check_res.get_items_by_action("prohibition"); !prohibit_items.empty()) { wxString info_msg; @@ -986,6 +1005,133 @@ static void _collect_filament_info(const wxString& shown_name, query_filament_types[shown_name] = filament.config.get_filament_type(); } +static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids"; + +const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const +{ + const int selection = m_comboBox_filament->GetSelection(); + if (selection < 0 || selection >= int(m_filament_list_items.size()) || !m_filament_list_items[selection].is_filament) + return nullptr; + return &m_filament_list_items[selection].filament; +} + +std::set AMSMaterialsSetting::load_favorite_filament_ids() const +{ + std::set favorite_ids; + std::vector values; + const std::string serialized = wxGetApp().app_config->get(AMS_FAVORITE_FILAMENT_IDS_KEY); + if (!serialized.empty() && Slic3r::unescape_strings_cstyle(serialized, values)) { + favorite_ids.insert(values.begin(), values.end()); + } + return favorite_ids; +} + +void AMSMaterialsSetting::save_favorite_filament_ids(const std::set& favorite_ids) +{ + std::vector values(favorite_ids.begin(), favorite_ids.end()); + wxGetApp().app_config->set(AMS_FAVORITE_FILAMENT_IDS_KEY, Slic3r::escape_strings_cstyle(values)); + wxGetApp().app_config->save(); +} + +void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& selected_filament_id) +{ + std::vector items; + m_filament_list_items.clear(); + + auto append_header = [&items, this](const wxString& label) { + DropDown::Item item; + item.text = label; + item.style = DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED; + items.push_back(item); + m_filament_list_items.push_back(FilamentListItem{}); + }; + + auto append_filament = [&items, this](const wxString& name) { + auto info_it = map_filament_items.find(name); + if (info_it == map_filament_items.end()) + return; + + DropDown::Item item; + item.text = name; + items.push_back(item); + m_filament_list_items.push_back(FilamentListItem{info_it->second, true}); + }; + + // Favorites are listed again at the top under their own header. Without any, the list is the + // plain filament list. + const std::set favorite_ids = load_favorite_filament_ids(); + std::vector favorites; + for (const wxString& name : m_sorted_filament_items) { + auto info_it = map_filament_items.find(name); + if (info_it != map_filament_items.end() && favorite_ids.count(info_it->second.filament_id) > 0) + favorites.push_back(name); + } + if (!favorites.empty()) { + append_header(_L("Favorite filaments")); + for (const wxString& name : favorites) + append_filament(name); + append_header(_L("All filaments")); + } + for (const wxString& name : m_sorted_filament_items) + append_filament(name); + + int selection_idx = -1; + if (!selected_filament_id.empty()) { + for (int i = 0; i < int(m_filament_list_items.size()); ++i) { + if (m_filament_list_items[i].is_filament && m_filament_list_items[i].filament.filament_id == selected_filament_id) { + selection_idx = i; + break; + } + } + } + + // Clear() first: SetItems() keeps the old selection, and SetSelection() ignores an index equal to it, + // which would leave the previous filament's name on the combo. + m_comboBox_filament->Clear(); + m_comboBox_filament->SetItems(items); + update_favorite_stars(); + m_comboBox_filament->SetSelection(selection_idx); + m_favorites_regroup = false; +} + +void AMSMaterialsSetting::update_favorite_stars() +{ + const std::set favorite_ids = load_favorite_filament_ids(); + for (size_t i = 0; i < m_filament_list_items.size(); ++i) { + const FilamentListItem& row = m_filament_list_items[i]; + if (row.is_filament) { + const bool favorite = favorite_ids.count(row.filament.filament_id) > 0; + m_comboBox_filament->SetItemActionBitmap(i, favorite ? m_star_filled.bmp() : m_star_empty.bmp()); + } + } +} + +void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id) +{ + if (filament_id.empty()) + return; + + std::set favorite_ids = load_favorite_filament_ids(); + auto iter = favorite_ids.find(filament_id); + if (iter == favorite_ids.end()) + favorite_ids.insert(filament_id); + else + favorite_ids.erase(iter); + + save_favorite_filament_ids(favorite_ids); +} + +void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt) +{ + const int index = evt.GetInt(); + if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament) + return; + + toggle_favorite_filament(m_filament_list_items[index].filament.filament_id); + update_favorite_stars(); + m_favorites_regroup = true; +} + void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n) { if (!obj) return; @@ -999,13 +1145,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi m_input_k_val->GetTextCtrl()->SetValue(k); m_input_n_val->GetTextCtrl()->SetValue(n); - wxArrayString filament_items; wxString bambu_filament_name; - wxString hint_filament_name; // the hint type to be selected std::unordered_map query_filament_vendors;// some information for sort std::unordered_map query_filament_types; // std::set filament_id_set; + m_sorted_filament_items.clear(); // The alias keyed map has to start empty: it is a member, so a stale alias left by an earlier // popup (a different printer, a different nozzle) would resolve to that printer's filament_id. map_filament_items.clear(); @@ -1035,7 +1180,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi if (alias.empty()) continue; - filament_items.push_back(alias); + m_sorted_filament_items.push_back(alias); _collect_filament_info(alias, *filament_it, query_filament_vendors, query_filament_types); FilamentInfos filament_infos; @@ -1044,7 +1189,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi map_filament_items[alias] = filament_infos; if (filament_it->filament_id == ams_filament_id) { - hint_filament_name = from_u8(alias); bambu_filament_name = from_u8(alias); @@ -1215,24 +1359,11 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi return name_compare != 0 ? name_compare < 0 : left < right; }; - std::sort(filament_items.begin(), filament_items.end(), _filament_sorter); + std::sort(m_sorted_filament_items.begin(), m_sorted_filament_items.end(), _filament_sorter); } - // traverse the hint selection idx - int selection_idx = -1; - { - for(int i = 0; i < filament_items.size(); i++) - { - if (hint_filament_name == filament_items[i]) - { - selection_idx = i; - break; - } - } - } - - m_comboBox_filament->Set(filament_items); - m_comboBox_filament->SetSelection(selection_idx); + rebuild_filament_dropdown_items(ams_filament_id); + int selection_idx = m_comboBox_filament->GetSelection(); post_select_event(selection_idx); if (selection_idx < 0) { @@ -1289,6 +1420,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) int* from_printer = static_cast(m_comboBox_filament->GetClientData()); m_filament_type = ""; + const FilamentInfos* selected_info = selected_filament_info(); PresetBundle* preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { std::ostringstream stream; @@ -1309,9 +1441,8 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) std::string nozzle_diameter_str = stream.str(); // Resolve the selection against the same list Popup() built the dropdown from, so the two // halves of the dialog cannot disagree about which filaments this machine can use. - const std::string selected = m_comboBox_filament->GetValue().ToStdString(); - if (!selected.empty()) { - const std::string filament_id = map_filament_items[selected].filament_id; + if (selected_info) { + const std::string& filament_id = selected_info->filament_id; for (Preset *it : preset_bundle->get_filament_presets_for_machine( DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) { if (it->filament_id != filament_id) @@ -1364,7 +1495,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) //reset cali int cali_select_idx = -1; - if ( !this->obj || m_filament_selection < 0) { + if ( !this->obj || m_filament_selection < 0 || !selected_info) { m_input_k_val->Enable(false); m_input_n_val->Enable(false); m_button_confirm->Disable(); // ORCA No need to change style @@ -1383,21 +1514,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) ams_filament_id = ""; ams_setting_id = ""; - if (preset_bundle) { - for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto itor = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); - if ( itor != map_filament_items.end()) { - ams_filament_id = itor->second.filament_id; - ams_setting_id = itor->second.setting_id; - break; - } - - if (it->alias.compare(m_comboBox_filament->GetValue().ToStdString()) == 0) { - ams_filament_id = it->filament_id; - ams_setting_id = it->setting_id; - break; - } - } + if (selected_info) { + ams_filament_id = selected_info->filament_id; + ams_setting_id = selected_info->setting_id; } wxArrayString items; @@ -1529,6 +1648,11 @@ void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect) m_button_reset->Rescale(); // ORCA m_button_confirm->Rescale(); // ORCA m_button_close->Rescale(); // ORCA + // An open list would keep the measurements of the old stars, so close it before replacing them. + m_comboBox_filament->CloseDropdown(); + m_star_filled.msw_rescale(); + m_star_empty.msw_rescale(); + update_favorite_stars(); this->Refresh(); } diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 2628e6caf4..6fa215063a 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -31,6 +31,7 @@ #include "slic3r/GUI/Widgets/StateColor.hpp" #include #include +#include #define AMS_MATERIALS_SETTING_DEF_COLOUR wxColour(255, 255, 255) #define AMS_MATERIALS_SETTING_GREY900 wxColour(38, 46, 48) @@ -156,6 +157,7 @@ protected: void create_panel_kn(wxWindow* parent); void on_dpi_changed(const wxRect &suggested_rect) override; void on_select_filament(wxCommandEvent& evt); + void on_toggle_favorite_filament(wxCommandEvent& evt); void on_select_cali_result(wxCommandEvent &evt); void on_select_ok(wxCommandEvent &event); void on_select_reset(wxCommandEvent &event); @@ -163,6 +165,11 @@ protected: void on_clr_picker(wxMouseEvent &event); bool is_virtual_tray(); void update_widgets(); + void rebuild_filament_dropdown_items(const std::string& selected_filament_id); + void update_favorite_stars(); + std::set load_favorite_filament_ids() const; + void save_favorite_filament_ids(const std::set& favorite_ids); + void toggle_favorite_filament(const std::string& filament_id); void update_filament_editing(bool is_printing); // Orca: the variant index of the filament's per-variant options on the nozzle this tray feeds @@ -202,12 +209,7 @@ protected: int m_pa_cali_select_id = 0; -// Orca: native wxComboBox on macOS (the custom ComboBox mis-renders the filament dropdown there) -#ifdef __APPLE__ - wxComboBox *m_comboBox_filament; -#else ComboBox *m_comboBox_filament; -#endif ComboBox * m_comboBox_cali_result; TextInput* m_readonly_filament; @@ -215,7 +217,20 @@ protected: std::string filament_id; std::string setting_id; }; - std::map map_filament_items; + struct FilamentListItem { + FilamentInfos filament; + bool is_filament{false}; + }; + const FilamentInfos* selected_filament_info() const; + + // Keyed by the same wxString the dropdown shows, so a row's name never has to be converted back. + std::map map_filament_items; + std::vector m_sorted_filament_items; + std::vector m_filament_list_items; + ScalableBitmap m_star_filled; + ScalableBitmap m_star_empty; + // Favorites changed while the list was open; their rows are regrouped before it opens again. + bool m_favorites_regroup{false}; }; wxDECLARE_EVENT(EVT_SELECTED_COLOR, wxCommandEvent); diff --git a/src/slic3r/GUI/CloneDialog.hpp b/src/slic3r/GUI/CloneDialog.hpp index 7d83b93d62..3233eb3b36 100644 --- a/src/slic3r/GUI/CloneDialog.hpp +++ b/src/slic3r/GUI/CloneDialog.hpp @@ -1,5 +1,4 @@ -#ifndef slic3r_GUI_SingleChoice_hpp_ -#define slic3r_GUI_SingleChoice_hpp_ +#pragma once #include "GUI_Utils.hpp" #include "Plater.hpp" @@ -33,5 +32,3 @@ private: }; }} // namespace Slic3r::GUI - -#endif \ No newline at end of file diff --git a/src/slic3r/GUI/ConfigExceptions.hpp b/src/slic3r/GUI/ConfigExceptions.hpp deleted file mode 100644 index e3c5b08cea..0000000000 --- a/src/slic3r/GUI/ConfigExceptions.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#include -#include - -#include "libslic3r/Exception.hpp" - -namespace Slic3r { - -class ConfigError : public Slic3r::RuntimeError { - using Slic3r::RuntimeError::RuntimeError; -}; - -namespace GUI { - -class ConfigGUITypeError : public ConfigError { - using ConfigError::ConfigError; -}; - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 8940e13e6e..11a436e1ae 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3232,9 +3232,16 @@ bool GUI_App::on_init_inner() #ifdef _MSW_DARK_MODE #ifndef __WINDOWS__ + // The label colours and the dark mode flag were set above from the saved mode. When the system theme + // differs from it, refresh them, or dialogs that map their colours stay in the old mode for this session. + const bool saved_dark_mode = dark_mode(); wxSystemAppearance app = wxSystemSettings::GetAppearance(); GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0"); GUI::wxGetApp().app_config->save(); + if (dark_mode() != saved_dark_mode) { + init_label_colours(); + Update_dark_mode_flag(); + } #endif // __APPLE__ @@ -4855,12 +4862,6 @@ void GUI_App::recreate_GUI(const wxString &msg_name) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "recreate_GUI exit"; } -void GUI_App::system_info() -{ - //SysInfoDialog dlg; - //dlg.ShowModal(); -} - void GUI_App::keyboard_shortcuts(ShortcutContext page, wxWindow* parent) { KBShortcutsDialog dlg(parent != nullptr ? parent : mainframe, page); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 0a2ddc5f17..e03c3d280a 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -519,7 +519,6 @@ public: void check_printer_presets(); void recreate_GUI(const wxString& message); - void system_info(); void keyboard_shortcuts(ShortcutContext page, wxWindow* parent = nullptr); // the main frame when null void troubleshoot(); void load_project(wxWindow *parent, wxString& input_file) const; diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 21210b06b7..4a733d3342 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -1617,7 +1617,7 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me int obj_idx, vol_idx; get_selected_item_indexes(obj_idx, vol_idx, item); if (obj_idx != -1) { - Plater::TakeSnapshot(plater, _u8L("Shift objects to bed")); + Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); (*m_objects)[obj_idx]->ensure_on_bed(); cnv->reload_scene(true, true); update_info_items(obj_idx); @@ -2779,12 +2779,13 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type) GLCanvas3D* cnv = plater->canvas3D(); switch (type) { - case InfoItemType::CustomSupports: + case InfoItemType::CustomSupports: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, "Remove support painting"); + Plater::TakeSnapshot snapshot(plater, "Remove support painting"); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->supported_facets.reset(); break; + } // BBS: remove CustomSeam @@ -2795,19 +2796,21 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type) } break; - case InfoItemType::MmSegmentation: + case InfoItemType::MmSegmentation: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, "Remove color painting"); + Plater::TakeSnapshot snapshot(plater, "Remove color painting"); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->mmu_segmentation_facets.reset(); break; + } - case InfoItemType::FuzzySkin: + case InfoItemType::FuzzySkin: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, _u8L("Remove paint-on fuzzy skin")); + Plater::TakeSnapshot snapshot(plater, _u8L("Remove paint-on fuzzy skin")); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->fuzzy_skin_facets.reset(); break; + } // BBS: remove Sinking case InfoItemType::Undef : assert(false); break; @@ -6718,7 +6721,7 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event) else if (col == colSinking) { Plater * plater = wxGetApp().plater(); GLCanvas3D *cnv = plater->canvas3D(); - Plater::TakeSnapshot(plater, _u8L("Shift objects to bed")); + Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); int obj_idx, vol_idx; get_selected_item_indexes(obj_idx, vol_idx, item); (*m_objects)[obj_idx]->ensure_on_bed(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 5985e4e1ea..f4493de6a5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -494,7 +494,17 @@ bool GLGizmoTextureDisplacement::on_init() {ctrl + _L("Mouse wheel"), m_desc["gap_area"]}, toggle_wireframe_shortcut }; - + + // Both ways into the same input box on any slider in the panel: ImGui turns a slider into one when + // it is Ctrl+clicked or when it is given the keyboard focus, which is what the edit icon asks for. + m_desc["type_value"] = _L("Type an exact value into a slider"); + m_shortcuts_sliders = { + {ctrl + _L("Left mouse button"), m_desc["type_value"]}, + {_L("Edit icon at a slider's right end"), m_desc["type_value"]}, + }; + for (std::vector> *list : {&m_shortcuts_brush, &m_shortcuts_bucket_fill, &m_shortcuts_gap_fill}) + list->insert(list->end(), m_shortcuts_sliders.begin(), m_shortcuts_sliders.end()); + return true; } @@ -3594,6 +3604,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; @@ -3629,6 +3640,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 @@ -3677,6 +3706,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) @@ -3685,6 +3722,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. @@ -3717,6 +3755,8 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", + // The "type a value instead" icon at the right end of every slider. + "edit.svg", }; std::vector paths; paths.reserve(names.size()); @@ -5976,8 +6016,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } return s + "..."; }; - // Label column + a slider filling the rest of the row, value printed inside the track. Ctrl+click on - // any of these types a value in. `right_inset` keeps a layer card's inner padding. + // Label column + a slider filling the rest of the row, value printed inside the track. + // `right_inset` keeps a layer card's inner padding. const auto slider_label = [&](const wxString &label) { const float x0 = ImGui::GetCursorPosX(); ImGui::AlignTextToFramePadding(); @@ -5985,17 +6025,107 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); }; + + // ---- Typed values on sliders ---- + // Every slider in the panel goes through typed_slider(), so all of them take a number the same + // two ways: Ctrl+click on the track, or the edit icon at the right end for the same box without + // the modifier. Both end up in ImGui's own "temp input" mode - the icon only asks ImGui to focus + // the slider, which is what turns it into an input box (see SliderScalar) - so the parsing, + // the clamping and the Enter/click-away behaviour are ImGui's, not a second implementation. + + // Retires a click whose row is no longer being drawn - the panel changed mode in between - so that + // it cannot fire later when that row comes back. Before any row is drawn, so a live one still wins. + if (!m_slider_type_request.empty() && ImGui::GetFrameCount() > m_slider_type_request_frame + 1) + m_slider_type_request.clear(); + + // Exactly as tall as the slider it sits beside, so it lines up with the track without the row + // having to be re-centred around it; icon_button() pads the glyph itself down to about 0.7 of this. + const float edit_icon_w = frame_h; + // What the slider's own width has to give up for the icon beside it. + const float edit_icon_room = edit_icon_w + gap_s; + + // One end of a range, through the slider's own format, so the bounds in the tooltip read exactly + // like the value in the track. A parenthesised note in the format - "(auto)" - is dropped: it + // describes the current state, not the bound. + const auto fmt_bound = [](const char *format, auto v) { + char buf[64]; + std::snprintf(buf, sizeof(buf), format, v); + std::string out(buf); + const size_t paren = out.find('('); + if (paren != std::string::npos) + out.erase(paren); + while (!out.empty() && out.back() == ' ') + out.pop_back(); + return out; + }; + const auto range_text = [](const std::string &lo, const std::string &hi) { + return Slic3r::format(_u8L("Range: %1% to %2%"), lo, hi); + }; + const auto edit_icon_tip = [&](const std::string &range) { + return _u8L("Type a value instead of dragging. Ctrl+click on the slider does the same.") + "\n\n" + range; + }; + + // Draws `slider` at `item_w` (SetNextItemWidth's convention: negative measures back from the right + // edge, and the caller has already left edit_icon_room in it), then the edit icon. `tip` and the + // range go on both. Returns whatever the slider returned. + const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::string &range, + const std::function &slider) -> bool { + // Deferred from the click a frame ago: SetKeyboardFocusHere() is a request for the *next* + // frame, so it has to be issued before the slider it names, not from the icon beside it. + if (m_slider_type_request == id) { + ImGui::SetKeyboardFocusHere(); + m_slider_type_request.clear(); + m_parent.set_as_dirty(); // the focus only lands on a frame that actually gets drawn + } + ImGui::SetNextItemWidth(item_w); + const bool changed = slider(); + // AllowWhenDisabled: several of these rows are drawn inside disabled_begin(), and a greyed + // slider is exactly when the user needs to be told what it is and why it is not moving. + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + wxString full = tip; + if (!full.IsEmpty()) + full += "\n\n"; + m_imgui->tooltip(full + from_u8(range), wrap_w); + } + // SameLine() puts the cursor back at the slider's own top (ItemSize records the Y the item was + // drawn at), so an icon of the same height lands on the track wherever the row placed it. + ImGui::SameLine(0.f, gap_s); + if (icon_button(int(ImGui::GetID(id)), "edit.svg", edit_icon_w, _L("Edit"), from_u8(edit_icon_tip(range)))) { + m_slider_type_request = id; + m_slider_type_request_frame = ImGui::GetFrameCount(); + m_parent.set_as_dirty(); // the next frame is where the request above is turned into focus + } + return changed; + }; + + // The two forms every slider in the panel is one of. They derive the range from the bounds they are + // already given, so no row states its bounds or its format twice and the tooltip cannot drift from + // the track. `item_w` is as typed_row() takes it. + const auto typed_slider = [&](const char *id, float item_w, float *v, float v_min, float v_max, const char *format, + bool log, const wxString &tip) -> bool { + return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + return ImGui::SliderFloat(id, v, v_min, v_max, format, + ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + }); + }; + const auto typed_slider_int = [&](const char *id, float item_w, int *v, int v_min, int v_max, const char *format, + bool log, const wxString &tip) -> bool { + return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + return ImGui::SliderInt(id, v, v_min, v_max, format, + ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + }); + }; + + // Label column + the slider filling the rest of the row. const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, const char *format, - bool log, float right_inset) -> bool { + bool log, float right_inset, const wxString &tip) -> bool { slider_label(label); - ImGui::SetNextItemWidth(-std::max(right_inset, 1.f)); - return ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip); }; const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max, const char *format, - float right_inset) -> bool { + float right_inset, const wxString &tip) -> bool { slider_label(label); - ImGui::SetNextItemWidth(-std::max(right_inset, 1.f)); - return ImGui::SliderInt(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp); + return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip); }; const auto layer_name = [](const TextureDisplacementLayer &l) { return l.name.empty() ? Slic3r::format(_u8L("Layer %1%"), l.slot + 1) : l.name; @@ -6043,6 +6173,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. @@ -6065,6 +6196,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(); @@ -6217,11 +6349,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const auto centre_on_row = [&](float h) { ImGui::SetCursorPosY(row_y + std::round((icon_md - h) * 0.5f)); }; vsep(icon_md); if (is_brush_mode) { - ImGui::SetNextItemWidth(std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm))); centre_on_row(frame_h); - ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", - ImGuiSliderFlags_AlwaysClamp); - hover_tip(m_desc.at("cursor_size")); + typed_slider("##cursor_radius", + std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm) - edit_icon_room), + &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size")); vsep(icon_sm); const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE; centre_on_row(icon_sm); @@ -6234,11 +6365,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float _L("Sphere - paints only within a ball around the point under the cursor"))) m_cursor_type = TriangleSelector::CursorType::SPHERE; } else if (is_area_mode) { - ImGui::SetNextItemWidth(std::max(1.f, row_end - ImGui::GetCursorPosX())); centre_on_row(frame_h); - ImGui::SliderFloat("##smart_fill_angle", &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", - ImGuiSliderFlags_AlwaysClamp); - hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this")); + typed_slider("##smart_fill_angle", std::max(1.f, row_end - ImGui::GetCursorPosX() - edit_icon_room), + &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false, + _L("Angle threshold - the fill stops at edges sharper than this")); } else { centre_on_row(frame_h); ImGui::AlignTextToFramePadding(); @@ -6541,22 +6671,22 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Midlevel: the height that means "don't move". At 0 the surface only ever bulges outwards; // at 0.5 mid-grey is neutral and darker texels cut inwards. - m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); - hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " - "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " - "still push out - one image both embosses and engraves.\n\n" - "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " - "cut can pass through the other side.")); + m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad, + _L("Which gray stays where the surface already is. At 0 the texture only pushes " + "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " + "still push out - one image both embosses and engraves.\n\n" + "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " + "cut can pass through the other side.")); if (layer.midlevel > 0.f && layer.depth_mm > 1.f) { ImGui::PushTextWrapPos(content_rx - card_pad); m_imgui->warning_text(_L("Deep inward displacement may self-intersect.")); ImGui::PopTextWrapPos(); } - m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad); - hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes " - "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " - "costs fine detail.")); + m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad, + _L("Blurs the image before it is used, which rounds off hard steps and removes " + "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " + "costs fine detail.")); // Edge fade: the relief flattens toward the boundary of the painted area. m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing); @@ -6564,12 +6694,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "blends into the bare surface instead of stopping at a step.")); ImGui::SameLine(); m_imgui->disabled_begin(!layer.edge_smoothing); - ImGui::SetNextItemWidth(-card_pad); - m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", - ImGuiSliderFlags_AlwaysClamp); + m_preview_params_dirty |= typed_slider("##edge_amount", -(card_pad + edit_icon_room), + &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", false, + _L("How far in the fade reaches, as a share of the painted area. Small " + "values soften a narrow band at the edge; 1 flattens almost all of it.")); m_imgui->disabled_end(); - hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " - "soften a narrow band at the edge; 1 flattens almost all of it.")); // Invert and Colours share a row. { @@ -6614,19 +6743,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Every mix can become a filament slot, and there are only so many beside the // physical filaments. const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); - if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad)) + if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad, + _L("The most mixed filaments a bake may add. They are picked from the " + "texture's colors, and only the ones the bake actually uses are created."))) m_preview_params_dirty = true; - hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the " - "texture's colors, and only the ones the bake actually uses are created.")); // After the slider, so a change shows in the same frame. ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } - if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad)) - m_preview_params_dirty = true; - hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer " + if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad, + _L("Cleans up single stray triangles of the wrong color, which detail finer " "than the mesh leaves behind. Raise it if the result looks speckled, " - "lower it if small features are being swallowed.")); + "lower it if small features are being swallowed."))) + m_preview_params_dirty = true; } } @@ -6744,10 +6873,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "gets the texture, and its border becomes the edge of the projection.")); if (projector_open) { - if (int_row("##projector_opacity", _L("Opacity"), &m_projector_opacity, 20, 255, "%d", card_pad)) + if (int_row("##projector_opacity", _L("Opacity"), &m_projector_opacity, 20, 255, "%d", card_pad, + _L("How solid the frame window looks while you place it. Lower to see the " + "model through it; it does not affect the result."))) m_projector_frame->set_opacity(m_projector_opacity); - hover_tip(_u8L("How solid the frame window looks while you place it. Lower to see the " - "model through it; it does not affect the result.")); if (m_imgui->button(_u8L("Apply projection frame"))) { const int painted_count = apply_projection_frame(); @@ -7064,57 +7193,57 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // pattern can sample four points that all happen to land at similar heights, report no error, // and stall before refinement ever starts. if (float_row("##subdiv_target", m_subdivide_feature ? _L("Max edge") : _L("Target edge"), &m_subdivide_target_mm, - 0.001f, 20.f, "%.3f mm", true, 0.f)) + 0.001f, 20.f, "%.3f mm", true, 0.f, + m_subdivide_feature ? + _L("No triangle in the painted area stays larger than this, even where the texture " + "is flat. Keep it near the size of the smallest feature you want to come out.") : + _L("Triangles in the painted area are split until none is larger than this. Smaller " + "means finer detail and, fast, a lot more triangles."))) preview_live(); - hover_tip(m_subdivide_feature ? - _u8L("No triangle in the painted area stays larger than this, even where the texture " - "is flat. Keep it near the size of the smallest feature you want to come out.") : - _u8L("Triangles in the painted area are split until none is larger than this. Smaller " - "means finer detail and, fast, a lot more triangles.")); if (m_subdivide_feature) { - if (float_row("##subdiv_detail", _L("Detail"), &m_subdivide_detail_mm, 0.001f, 1.f, "%.3f mm", true, 0.f)) + if (float_row("##subdiv_detail", _L("Detail"), &m_subdivide_detail_mm, 0.001f, 1.f, "%.3f mm", true, 0.f, + _L("How far the mesh may sit from the shape the texture describes, in millimetres. " + "Smaller follows fine detail and costs triangles; larger only chases the big " + "features."))) preview_live(); - hover_tip(_u8L("How far the mesh may sit from the shape the texture describes, in millimetres. " - "Smaller follows fine detail and costs triangles; larger only chases the big " - "features.")); - if (float_row("##subdiv_min", _L("Min edge"), &m_subdivide_min_edge_mm, 0.001f, 20.f, "%.3f mm", true, 0.f)) + if (float_row("##subdiv_min", _L("Min edge"), &m_subdivide_min_edge_mm, 0.001f, 20.f, "%.3f mm", true, 0.f, + _L("A floor on triangle size, so a sharp step in the texture cannot be chased " + "forever. Lower it for finer relief, raise it if the count runs away at hard " + "edges."))) preview_live(); - hover_tip(_u8L("A floor on triangle size, so a sharp step in the texture cannot be chased " - "forever. Lower it for finer relief, raise it if the count runs away at hard " - "edges.")); } // Applies in both adaptive sub-modes: it is about the step the bake puts at the paint's edge, which // exists whether or not "Follow texture detail" is on. 0 turns the band off. - if (float_row("##subdiv_border", _L("Edge detail"), &m_subdivide_border_mm, 0.f, 5.f, "%.3f mm", false, 0.f)) + if (float_row("##subdiv_border", _L("Edge detail"), &m_subdivide_border_mm, 0.f, 5.f, "%.3f mm", false, 0.f, + _L("Triangle size along the outline of the painted area, where the relief drops back to the " + "bare surface. Lower it if that rim looks jagged; it only costs triangles along the " + "outline. 0 turns it off."))) preview_live(); - hover_tip(_u8L("Triangle size along the outline of the painted area, where the relief drops back to the " - "bare surface. Lower it if that rim looks jagged; it only costs triangles along the " - "outline. 0 turns it off.")); // Only worth showing when a layer is actually colouring: with no colour there is no boundary for it // to refine and the control would do nothing whatever it is set to. if (mv != nullptr && any_layer_colors(*mv)) { - if (float_row("##subdiv_color", _L("Color detail"), &m_subdivide_color_mm, 0.f, 5.f, "%.3f mm", false, 0.f)) + if (float_row("##subdiv_color", _L("Color detail"), &m_subdivide_color_mm, 0.f, 5.f, "%.3f mm", false, 0.f, + _L("Triangle size where two colors meet. Each triangle prints in one filament, so a " + "color edge can only be as sharp as the triangles along it - and nothing else " + "refines there, since the surface is flat across a change of color. 0 turns it off."))) preview_live(); - hover_tip(_u8L("Triangle size where two colors meet. Each triangle prints in one filament, so a " - "color edge can only be as sharp as the triangles along it - and nothing else " - "refines there, since the surface is flat across a change of color. 0 turns it off.")); } ImGui::TextDisabled("%s", _u8L("Triangle budget: set with Triangles, below.").c_str()); if (m_subdivide_editing && m_subdivide_preview_tris > 0) m_imgui->text(Slic3r::format(_u8L("Preview: %1% triangles"), m_subdivide_preview_tris)); } else { - if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f)) { + if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f, + _L("Splits every triangle of the model into four, this many times over. Each step " + "quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining " + "only the painted area unless you need the whole model finer."))) { if (m_subdivide_editing) rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one m_parent.set_as_dirty(); } - hover_tip(_u8L("Splits every triangle of the model into four, this many times over. Each step " - "quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining " - "only the painted area unless you need the whole model finer.")); } { @@ -7176,9 +7305,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_remesh_target_edge_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)), 0.1f, 20.f) : 1.f; } - float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f); - hover_tip(_u8L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an " - "even mesh; this is what makes one out of an uneven import.")); + float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f, + _L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an " + "even mesh; this is what makes one out of an uneven import.")); m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges); hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it " @@ -7186,11 +7315,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "a cube comes back with wobbly edges.")); ImGui::SameLine(); m_imgui->disabled_begin(!m_remesh_keep_sharp_edges); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("##remesh_sharp_angle", &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", ImGuiSliderFlags_AlwaysClamp); - m_imgui->disabled_end(); - hover_tip(_u8L("How sharp a fold has to be, in degrees, before the remesher treats it as an edge worth " + typed_slider("##remesh_sharp_angle", -(1.f + edit_icon_room), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", false, + _L("How sharp a fold has to be, in degrees, before the remesher treats it as an edge worth " "keeping. Lower protects more of the model's shape; higher rebuilds more of it evenly.")); + m_imgui->disabled_end(); m_imgui->disabled_begin(mv == nullptr || busy); if (ImGui::Button(_u8L("Remesh").c_str())) @@ -7218,16 +7346,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "on a layer is a different thing: it blurs the image before it is used.")); if (opts.smooth_enabled) { float percent = opts.smooth_strength * 100.f; - if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f)) { + if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f, + _L("How far each pass pulls a vertex towards its neighbours. High values round the " + "relief off quickly; low values need more passes but keep more of the detail."))) { opts.smooth_strength = std::clamp(percent / 100.f, 0.01f, 1.f); m_preview_params_dirty = true; } - hover_tip(_u8L("How far each pass pulls a vertex towards its neighbours. High values round the " - "relief off quickly; low values need more passes but keep more of the detail.")); - if (int_row("##dispsmoothit", _L("Passes"), &opts.smooth_iterations, 1, 10, "%d", 0.f)) + if (int_row("##dispsmoothit", _L("Passes"), &opts.smooth_iterations, 1, 10, "%d", 0.f, + _L("How many smoothing passes to run. More passes spread the smoothing further across " + "the surface; Strength decides how much each one moves."))) m_preview_params_dirty = true; - hover_tip(_u8L("How many smoothing passes to run. More passes spread the smoothing further across " - "the surface; Strength decides how much each one moves.")); m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Ignore outer ring"), opts.smooth_skip_border); hover_tip(_u8L("Keeps the outer ring of the painted area out of the smoothing. Its neighbours " @@ -7270,16 +7398,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float int shown_k = auto_budget ? v2_recommendation(*mv).budget_k : opts.v2_max_triangles_k; m_imgui->disabled_begin(auto_budget); // TRN Slider value: %d is the triangle budget in thousands - if (int_row("##v2budget", _L("Budget"), &shown_k, 0, 4000, auto_budget ? _u8L("%d k (auto)").c_str() : "%d k", 0.f)) { + if (int_row("##v2budget", _L("Budget"), &shown_k, 0, 4000, auto_budget ? _u8L("%d k (auto)").c_str() : "%d k", 0.f, + _L("How many thousand triangles this bake may spend on the area you painted. " + "Relief already baked into the rest of the model is kept on top of it, so a " + "second bake somewhere else gets the same budget as the first. Raise it if the " + "warning below Resolution says the detail will not fit; 0 keeps every triangle " + "the refinement produced, however many that is."))) { opts.v2_max_triangles_k = shown_k; m_preview_params_dirty = true; } m_imgui->disabled_end(); - hover_tip(_u8L("How many thousand triangles this bake may spend on the area you painted. " - "Relief already baked into the rest of the model is kept on top of it, so a " - "second bake somewhere else gets the same budget as the first. Raise it if the " - "warning below Resolution says the detail will not fit; 0 keeps every triangle " - "the refinement produced, however many that is.")); } if (SHOW_PIPELINE_DEV_CONTROLS && opts.pipeline_v2) { // Keeping the relief above the plate is not a checkbox: it is unconditional, in both pipelines @@ -7386,26 +7514,28 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "default. Untick to set both yourself - worth doing for a texture much finer or " "much coarser than the part it sits on.")); ImGui::SameLine(); - ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); float shown = auto_res ? rec.edge_mm : opts.v2_refine_mm; + // Held in a local: the format outlives the call that reads it, and the slider and the + // range in its tooltip are then formatted the same way. + const std::string v2edge_format = auto_res ? _u8L("%.2f mm (auto)") : std::string("%.2f mm"); + const wxString v2edge_tip = + auto_res && rec.edge_mm > 0.f ? + from_u8(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, " + "budget %2% k. Untick Auto to set them yourself."), + rec.edge_mm, rec.budget_k)) : + _L("How fine the mesh is made under the paint, in millimetres. It has to be " + "smaller than the detail you want out of the texture - a 0.5 mm groove needs " + "triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs " + "four times as many, and once they no longer fit the budget the bake simplifies " + "back down and the detail goes with it."); m_imgui->disabled_begin(busy || auto_res); - if (ImGui::SliderFloat("##v2edge", &shown, 0.02f, 2.f, auto_res ? _u8L("%.2f mm (auto)").c_str() : "%.2f mm", - ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + if (typed_slider("##v2edge", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, &shown, 0.02f, 2.f, + v2edge_format.c_str(), true, v2edge_tip)) { opts.v2_refine_mm = shown; m_preview_params_dirty = true; m_parent.set_as_dirty(); } m_imgui->disabled_end(); - if (auto_res && rec.edge_mm > 0.f) - hover_tip(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, " - "budget %2% k. Untick Auto to set them yourself."), - rec.edge_mm, rec.budget_k)); - else - hover_tip(_u8L("How fine the mesh is made under the paint, in millimetres. It has to be " - "smaller than the detail you want out of the texture - a 0.5 mm groove needs " - "triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs " - "four times as many, and once they no longer fit the budget the bake simplifies " - "back down and the detail goes with it.")); // What this resolution costs over what is painted, against what the budget allows. Refining // past the budget is not an error - the bake simplifies back down to it - but the result @@ -7437,18 +7567,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->text(_L("Triangles")); ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); - ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); m_imgui->disabled_begin(busy); - if (ImGui::SliderInt("##subdivbudget", &m_subdivide_budget_k, 10, 2000, "+%d k", - ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + if (typed_slider_int("##subdivbudget", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, + &m_subdivide_budget_k, 10, 2000, "+%d k", true, + _L("How many thousand triangles the refinement may add to the model. The triangle " + "that fits worst is always split first, so even a run that spends the lot has " + "spent it where it shows most. Raise it if the relief still looks coarse."))) { if (m_subdivide_editing) rebuild_subdivide_preview(); m_parent.set_as_dirty(); } m_imgui->disabled_end(); - hover_tip(_u8L("How many thousand triangles the refinement may add to the model. The triangle " - "that fits worst is always split first, so even a run that spends the lot has " - "spent it where it shows most. Raise it if the relief still looks coarse.")); } const float button_h = std::round(frame_h * 1.25f); @@ -7467,7 +7596,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float : m_tool_type == ToolType::SMART_FILL ? m_shortcuts_bucket_fill : m_tool_type == ToolType::BRUSH ? m_shortcuts_brush : m_tool_type == ToolType::GAP_FILL ? m_shortcuts_gap_fill - : std::vector>{}; + : m_shortcuts_sliders; // the sliders are there whether or not a painting tool is ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f); // center vertically ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y)); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index c76abf4375..ec099b9078 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -268,6 +268,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 @@ -410,6 +413,12 @@ private: // Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation. // Panel state only, not saved with the project. std::array m_layer_expanded{}; + // The slider whose edit icon was just clicked, as its ImGui id, and the frame it was clicked on. + // The row acts on it on the next frame - ImGui's keyboard-focus request only takes effect a frame + // later - and clears it; the frame number is what retires a request whose row has gone off screen + // in the meantime. Panel state only, not saved with the project. + std::string m_slider_type_request; + int m_slider_type_request_frame = 0; // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane @@ -990,9 +999,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 @@ -1035,6 +1047,9 @@ private: std::vector> m_shortcuts_bucket_fill; // Contains all shortcuts in the format of {shortcut, description}, e.g. {alt + _L("Left mouse button"), _L("Part_selection")} std::vector> m_shortcuts_gap_fill; + // The two ways every slider in the panel takes a typed value. They hold whatever tool is selected, + // so they are tacked onto each of the lists above and stand on their own when no tool is active. + std::vector> m_shortcuts_sliders; // Icons for the panel's icon buttons (tools, views, mapping, tiling, layer actions). Loaded through IconManager with // the same colour/monochrome variants the main toolbar uses, so an inactive button shows the icon in diff --git a/src/slic3r/GUI/MultiTaskModel.cpp b/src/slic3r/GUI/MultiTaskModel.cpp deleted file mode 100644 index b23468b93e..0000000000 --- a/src/slic3r/GUI/MultiTaskModel.cpp +++ /dev/null @@ -1,6 +0,0 @@ - -namespace Slic3r { -namespace GUI { - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/MultiTaskModel.hpp b/src/slic3r/GUI/MultiTaskModel.hpp deleted file mode 100644 index 6b233e9302..0000000000 --- a/src/slic3r/GUI/MultiTaskModel.hpp +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef slic3r_MultiTaskModel_hpp_ -#define slic3r_MultiTaskModel_hpp_ - -namespace Slic3r { -namespace GUI { - - -} // namespace GUI -} // namespace Slic3r - -#endif diff --git a/src/slic3r/GUI/SysInfoDialog.cpp b/src/slic3r/GUI/SysInfoDialog.cpp deleted file mode 100644 index 585767d318..0000000000 --- a/src/slic3r/GUI/SysInfoDialog.cpp +++ /dev/null @@ -1,232 +0,0 @@ -#include "SysInfoDialog.hpp" -#include "I18N.hpp" -#include "3DScene.hpp" -#include "GUI.hpp" -#include "../Utils/UndoRedo.hpp" -#include "Plater.hpp" - -#include - -#include - -#include -#include -#include "GUI_App.hpp" -#include "MainFrame.hpp" -#include "wxExtensions.hpp" -#include "libslic3r/BlacklistedLibraryCheck.hpp" -#include "libslic3r/Color.hpp" -#include "format.hpp" - -#ifdef _WIN32 - // The standard Windows includes. - #define WIN32_LEAN_AND_MEAN - #ifndef NOMINMAX - #define NOMINMAX - #endif - #include - #include -#endif /* _WIN32 */ - -namespace Slic3r { -namespace GUI { - -std::string get_main_info(bool format_as_html) -{ - std::stringstream out; - - std::string b_start = format_as_html ? "" : ""; - std::string b_end = format_as_html ? "" : ""; - std::string line_end = format_as_html ? "
" : "\n"; - - if (!format_as_html) - out << b_start << (wxGetApp().is_editor() ? SLIC3R_APP_FULL_NAME : GCODEVIEWER_APP_NAME) << b_end << line_end; - out << b_start << "Version: " << b_end << SLIC3R_VERSION << line_end; - out << b_start << "Build: " << b_end << (wxGetApp().is_editor() ? SLIC3R_BUILD_ID : GCODEVIEWER_BUILD_ID) << line_end; - out << line_end; - out << b_start << "Operating System: " << b_end << wxPlatformInfo::Get().GetOperatingSystemFamilyName() << line_end; - out << b_start << "System Architecture: " << b_end << wxPlatformInfo::Get().GetArchName() << line_end; - out << b_start << -#if defined _WIN32 - "Windows Version: " -#else - // Hopefully some kind of unix / linux. - "System Version: " -#endif - << b_end << wxPlatformInfo::Get().GetOperatingSystemDescription() << line_end; - out << b_start << "Total RAM size [MB]: " << b_end << Slic3r::format_memsize_MB(Slic3r::total_physical_memory()); - - return out.str(); -} - -std::string get_mem_info(bool format_as_html) -{ - std::stringstream out; - - std::string b_start = format_as_html ? "" : ""; - std::string b_end = format_as_html ? "" : ""; - std::string line_end = format_as_html ? "
" : "\n"; - - std::string mem_info_str = log_memory_info(true); - std::istringstream mem_info(mem_info_str); - std::string value; - while (std::getline(mem_info, value, ':')) { - out << b_start << (value+": ") << b_end; - std::getline(mem_info, value, ';'); - out << value << line_end; - } - - const Slic3r::UndoRedo::Stack &stack = wxGetApp().plater()->undo_redo_stack_main(); - out << b_start << "RAM size reserved for the Undo / Redo stack: " << b_end << Slic3r::format_memsize_MB(stack.get_memory_limit()) << line_end; - out << b_start << "RAM size occupied by the Undo / Redo stack: " << b_end << Slic3r::format_memsize_MB(stack.memsize()) << line_end << line_end; - - return out.str(); -} - -SysInfoDialog::SysInfoDialog() - : DPIDialog(static_cast(wxGetApp().mainframe), wxID_ANY, (wxGetApp().is_editor() ? wxString(SLIC3R_APP_FULL_NAME) : wxString(GCODEVIEWER_APP_NAME)) + " - " + _L("System Information"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) -{ - wxColour bgr_clr = wxGetApp().get_window_default_clr();//wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); - SetBackgroundColour(bgr_clr); - SetFont(wxGetApp().normal_font()); - - wxBoxSizer* hsizer = new wxBoxSizer(wxHORIZONTAL); - hsizer->SetMinSize(wxSize(50 * wxGetApp().em_unit(), -1)); - - auto main_sizer = new wxBoxSizer(wxVERTICAL); - main_sizer->Add(hsizer, 1, wxEXPAND | wxALL, 10); - - // logo - m_logo_bmp = ScalableBitmap(this, wxGetApp().logo_name(), 192); - m_logo = new wxStaticBitmap(this, wxID_ANY, m_logo_bmp.bmp()); - hsizer->Add(m_logo, 0, wxALIGN_CENTER_VERTICAL); - - wxBoxSizer* vsizer = new wxBoxSizer(wxVERTICAL); - hsizer->Add(vsizer, 1, wxEXPAND|wxLEFT, 20); - - // title - { - wxStaticText* title = new wxStaticText(this, wxID_ANY, wxGetApp().is_editor() ? SLIC3R_APP_FULL_NAME : GCODEVIEWER_APP_NAME, wxDefaultPosition, wxDefaultSize); - wxFont title_font = wxGetApp().bold_font(); - title_font.SetFamily(wxFONTFAMILY_ROMAN); - title_font.SetPointSize(22); - title->SetFont(title_font); - vsizer->Add(title, 0, wxEXPAND | wxTOP, wxGetApp().em_unit()/*50*/); - } - - // main_info_text - wxFont font = get_default_font(this); - const auto text_clr = wxGetApp().get_label_clr_default();//wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT); - auto text_clr_str = encode_color(ColorRGB(text_clr.Red(), text_clr.Green(), text_clr.Blue())); - auto bgr_clr_str = encode_color(ColorRGB(bgr_clr.Red(), bgr_clr.Green(), bgr_clr.Blue())); - - const int fs = font.GetPointSize() - 1; - int size[] = { static_cast(fs*1.5), static_cast(fs*1.4), static_cast(fs*1.3), fs, fs, fs, fs }; - - m_html = new wxHtmlWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHW_SCROLLBAR_NEVER); - { - m_html->SetFonts(font.GetFaceName(), font.GetFaceName(), size); - m_html->SetBorders(2); - const auto text = wxString::Format( - "" - "" - "" - "%s" - "" - "" - "", bgr_clr_str, text_clr_str, text_clr_str, - get_main_info(true)); - m_html->SetPage(text); - vsizer->Add(m_html, 1, wxEXPAND | wxBOTTOM, wxGetApp().em_unit()); - } - - // opengl_info - m_opengl_info_html = new wxHtmlWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHW_SCROLLBAR_AUTO); - { - m_opengl_info_html->SetMinSize(wxSize(-1, 16 * wxGetApp().em_unit())); - m_opengl_info_html->SetFonts(font.GetFaceName(), font.GetFaceName(), size); - m_opengl_info_html->SetBorders(10); - wxString blacklisted_libraries_message; -#ifdef WIN32 - std::wstring blacklisted_libraries = BlacklistedLibraryCheck::get_instance().get_blacklisted_string().c_str(); - if (! blacklisted_libraries.empty()) - blacklisted_libraries_message = wxString("
") + _L("Blacklisted libraries loaded into OrcaSlicer process:") + "
" + blacklisted_libraries; -#endif // WIN32 - const auto text = GUI::format_wxstr( - "" - "" - "" - "%s
%s
%s
%s" - "
" - "" - "", bgr_clr_str, text_clr_str, text_clr_str, - blacklisted_libraries_message, - get_mem_info(true), wxGetApp().get_gl_info(false), - "" + _L("SIMD is supported:") + " " + Eigen::SimdInstructionSetsInUse()); - - m_opengl_info_html->SetPage(text); - main_sizer->Add(m_opengl_info_html, 1, wxEXPAND | wxBOTTOM, 15); - } - - wxStdDialogButtonSizer* buttons = this->CreateStdDialogButtonSizer(wxOK); - m_btn_copy_to_clipboard = new wxButton(this, wxID_ANY, _L("Copy to Clipboard"), wxDefaultPosition, wxDefaultSize); - - buttons->Insert(0, m_btn_copy_to_clipboard, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 5); - m_btn_copy_to_clipboard->Bind(wxEVT_BUTTON, &SysInfoDialog::onCopyToClipboard, this); - - this->SetEscapeId(wxID_OK); - this->Bind(wxEVT_BUTTON, &SysInfoDialog::onCloseDialog, this, wxID_OK); - main_sizer->Add(buttons, 0, wxEXPAND | wxRIGHT | wxBOTTOM, 3); - - wxGetApp().UpdateDlgDarkUI(this, true); - -// this->Bind(wxEVT_LEFT_DOWN, &SysInfoDialog::onCloseDialog, this); -// logo->Bind(wxEVT_LEFT_DOWN, &SysInfoDialog::onCloseDialog, this); - - SetSizer(main_sizer); - main_sizer->SetSizeHints(this); -} - -void SysInfoDialog::on_dpi_changed(const wxRect &suggested_rect) -{ - m_logo_bmp.msw_rescale(); - m_logo->SetBitmap(m_logo_bmp.bmp()); - - wxFont font = get_default_font(this); - const int fs = font.GetPointSize() - 1; - int font_size[] = { static_cast(fs*1.5), static_cast(fs*1.4), static_cast(fs*1.3), fs, fs, fs, fs }; - - m_html->SetFonts(font.GetFaceName(), font.GetFaceName(), font_size); - m_html->Refresh(); - - const int& em = em_unit(); - - msw_buttons_rescale(this, em, { wxID_OK, m_btn_copy_to_clipboard->GetId() }); - - m_opengl_info_html->SetMinSize(wxSize(-1, 16 * em)); - m_opengl_info_html->SetFonts(font.GetFaceName(), font.GetFaceName(), font_size); - m_opengl_info_html->Refresh(); - - const wxSize& size = wxSize(65 * em, 55 * em); - - SetMinSize(size); - Fit(); - - Refresh(); -} - -void SysInfoDialog::onCopyToClipboard(wxEvent &) -{ - wxTheClipboard->Open(); - const auto text = get_main_info(false) + "\n" + wxGetApp().get_gl_info(true); - wxTheClipboard->SetData(new wxTextDataObject(text)); - wxTheClipboard->Close(); -} - -void SysInfoDialog::onCloseDialog(wxEvent &) -{ - this->EndModal(wxID_CLOSE); -} - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/SysInfoDialog.hpp b/src/slic3r/GUI/SysInfoDialog.hpp deleted file mode 100644 index 25a59f0fa7..0000000000 --- a/src/slic3r/GUI/SysInfoDialog.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#ifndef slic3r_GUI_SysInfoDialog_hpp_ -#define slic3r_GUI_SysInfoDialog_hpp_ - -#include -#include -#include - -#include "GUI_Utils.hpp" -#include "wxExtensions.hpp" - -namespace Slic3r { -namespace GUI { - -class SysInfoDialog : public DPIDialog -{ - ScalableBitmap m_logo_bmp; - wxStaticBitmap* m_logo; - wxHtmlWindow* m_opengl_info_html; - wxHtmlWindow* m_html; - - wxButton* m_btn_copy_to_clipboard; - -public: - SysInfoDialog(); - -protected: - void on_dpi_changed(const wxRect &suggested_rect) override; - -private: - void onCopyToClipboard(wxEvent &); - void onCloseDialog(wxEvent &); -}; -} // namespace GUI -} // namespace Slic3r - -#endif diff --git a/src/slic3r/GUI/WebUpdatePlugin.cpp b/src/slic3r/GUI/WebUpdatePlugin.cpp deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/slic3r/GUI/WebUpdatePlugin.hpp b/src/slic3r/GUI/WebUpdatePlugin.hpp deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index b02e1a8416..55e261d04d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -82,6 +82,11 @@ ComboBox::ComboBox(wxWindow *parent, e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + drop.Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); drop.Bind(EVT_DISMISS, [this](auto &) { drop_down = false; wxCommandEvent e(wxEVT_COMBOBOX_CLOSEUP); @@ -251,7 +256,7 @@ int ComboBox::Append(const wxString &text, int style) { auto valid_bit_map = bitmap.IsOk() ? bitmap : wxNullBitmap; - Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, clientData, group_key, group_label}; + Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, wxNullBitmap, clientData, group_key, group_label}; item.style = style; items.push_back(item); SetClientDataType(wxClientData_Void); @@ -297,6 +302,12 @@ void ComboBox::ForceDropdownOpen() } } +void ComboBox::CloseDropdown() +{ + if (drop.IsShown()) + drop.DismissAndNotify(); +} + unsigned int ComboBox::GetCount() const { return items.size(); } void ComboBox::set_replace_text(wxString text, wxString image_name) @@ -350,6 +361,13 @@ void ComboBox::SetItemBitmap(unsigned int n, wxBitmap const &bitmap) drop.Invalidate(); } +void ComboBox::SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap) +{ + if (n >= items.size()) return; + items[n].action_icon = bitmap.IsOk() ? bitmap : wxNullBitmap; + drop.Invalidate(); +} + int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, unsigned int pos, void ** clientData, @@ -357,7 +375,7 @@ int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, { if (pos > this->items.size()) return -1; for (int i = 0; i < items.GetCount(); ++i) { - Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; + Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; this->items.insert(this->items.begin() + pos, item); ++pos; } @@ -419,16 +437,18 @@ void ComboBox::keyDown(wxKeyEvent& event) case WXK_UP: case WXK_DOWN: case WXK_LEFT: - case WXK_RIGHT: - if ((event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) && GetSelection() > 0) { - SetSelection(GetSelection() - 1); - } else if ((event.GetKeyCode() == WXK_DOWN || event.GetKeyCode() == WXK_RIGHT) && GetSelection() + 1 < items.size()) { - SetSelection(GetSelection() + 1); - } else { + case WXK_RIGHT: { + // Step over disabled rows: the mouse cannot select them either. + const int step = (event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) ? -1 : 1; + int n = GetSelection() + step; + while (n >= 0 && n < int(items.size()) && (items[n].style & DD_ITEM_STYLE_DISABLED)) + n += step; + if (n < 0 || n >= int(items.size())) break; - } + SetSelection(n); sendComboBoxEvent(); break; + } case WXK_TAB: HandleAsNavigationKey(event); break; diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 1dae23b85d..1fbc2e257d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -96,10 +96,12 @@ public: wxBitmap GetItemBitmap(unsigned int n); void SetItemBitmap(unsigned int n, wxBitmap const &bitmap); + void SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap); bool is_drop_down(){return drop_down;} void DeleteOneItem(unsigned int pos) { DoDeleteOneItem(pos); } void ForceDropdownOpen(); + void CloseDropdown(); protected: virtual int DoInsertItems(const wxArrayStringsAdapter &items, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index c5a8e280b1..966a090526 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -30,6 +30,7 @@ #include "slic3r/GUI/wxExtensions.hpp" wxDEFINE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDEFINE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); BEGIN_EVENT_TABLE(DropDown, PopupWindow) @@ -242,6 +243,19 @@ static wxSize GetBmpSize(wxBitmap & bmp) #endif } +// The x-range [first, second) of the column at the right of the rows that holds the items' action +// icons, empty when no item has one. render() draws the icons in it and mouseReleased() hit-tests +// clicks against it, so the drawn icon and its clickable area always match. +std::pair DropDown::actionColumn() const +{ + if (actionIconWidth <= 0) + return {0, 0}; + int right = rowSize.x; + if (rowSize.y * count > GetSize().y) // position bar + right -= 6; + return {right - actionIconWidth - FromDIP(16), right}; +} + static void _DrawSplitItem(const wxWindow* w, wxDC& dc, wxString split_text, wxPoint start_pt, int item_width, int item_height) { // save dc @@ -371,6 +385,10 @@ void DropDown::render(wxDC &dc) rcContent.width -= szBmp.x + 5; } + const auto action_column = actionColumn(); + if (action_column.second > action_column.first) + rcContent.width = action_column.first - rcContent.x; + std::set groups; // draw texts & icons int index = 0; @@ -434,13 +452,24 @@ void DropDown::render(wxDC &dc) auto text = group.IsEmpty() ? (item.group_key.IsEmpty() ? item.text : item.group_label) : item.text; + if (item.action_icon.IsOk()) { + auto action_size = GetBmpSize(item.action_icon); + wxPoint action_pt(action_column.second - action_size.x - FromDIP(8), + rcContent.y + (rcContent.height - action_size.y) / 2); + dc.DrawBitmap(item.action_icon, action_pt); + } + if (!text_off && !text.IsEmpty()) { wxSize tSize = dc.GetMultiLineTextExtent(text); if (pt.x + tSize.x > rcContent.GetRight()) { - if (is_hover && item.tip.IsEmpty()) - SetToolTip(text); - text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, - rcContent.GetRight() - pt.x); + int max_text_width = rcContent.GetRight() - pt.x; + if (max_text_width <= 0) { + text.clear(); + } else { + if (is_hover && item.tip.IsEmpty()) + SetToolTip(text); + text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, max_text_width); + } } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); @@ -529,6 +558,7 @@ void DropDown::messureSize() if (!need_sync) return; textSize = wxSize(); iconSize = wxSize(); + actionIconWidth = 0; count = 0; wxClientDC dc(GetParent() ? GetParent() : this); dc.SetFont(GetFont()); @@ -565,8 +595,13 @@ void DropDown::messureSize() size1.x += size2.x + (text_off ? 0 : 5); } } + if (item.action_icon.IsOk()) + actionIconWidth = std::max(actionIconWidth, GetBmpSize(item.action_icon).x); if (size1.x > textSize.x) textSize = size1; } + // Every row leaves room for the action column, as render() and actionColumn() lay it out. + if (actionIconWidth > 0) + textSize.x += actionIconWidth + FromDIP(16); if (!align_icon) iconSize.x = 0; wxSize szContent = textSize; if (szContent.x < FromDIP(120) && !use_content_width) @@ -636,6 +671,11 @@ void DropDown::messureSize() e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + subDropDown->Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); } need_sync = false; } @@ -714,6 +754,15 @@ void DropDown::mouseReleased(wxMouseEvent& event) if (hover_item < 0) return; if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) { // not moved + int index = hoverIndex(); + if (index >= 0 && items[index].action_icon.IsOk()) { + const auto action_column = actionColumn(); + if (event.GetX() >= action_column.first && event.GetX() < action_column.second) { + sendDropDownActionEvent(index); + paintNow(); + return; + } + } sendDropDownEvent(); if (mainDropDown) mainDropDown->hover_item = -1; // To Dismiss mainDropDown @@ -830,6 +879,17 @@ void DropDown::sendDropDownEvent() GetEventHandler()->ProcessEvent(event); } +void DropDown::sendDropDownActionEvent(int index) +{ + if (index < 0 || index >= int(items.size()) || (items[index].style & DD_ITEM_STYLE_DISABLED)) + return; + wxCommandEvent event(EVT_DROPDOWN_ITEM_ACTION, GetId()); + event.SetEventObject(this); + event.SetInt(index); + event.SetString(items[index].text); + GetEventHandler()->ProcessEvent(event); +} + void DropDown::Dismiss() { if (subDropDown && subDropDown->IsShown()) diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 8b6ae5d635..a0821e6fff 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "slic3r/GUI/Widgets/StateColor.hpp" #include @@ -27,6 +28,7 @@ class wxWindow; #define DD_ITEM_STYLE_DIMMED 0x0004 // gray text, but still selectable wxDECLARE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDECLARE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); class DropDown : public PopupWindow { @@ -37,6 +39,7 @@ public: wxString text_static_tips;// display static tips for TextInput.eg.PrinterInfoBox wxBitmap icon; wxBitmap icon_textctrl;// display icon for TextInput.eg.PrinterInfoBox + wxBitmap action_icon; void * data{nullptr}; wxString group_key{}; wxString group_label{}; @@ -65,6 +68,7 @@ private: wxSize textSize; wxSize iconSize; + int actionIconWidth = 0; wxSize rowSize; StateHandler state_handler; @@ -135,6 +139,7 @@ private: int hoverIndex(); int selectedItem(); + std::pair actionColumn() const; friend class ComboBox; void messureSize(); @@ -148,6 +153,7 @@ private: void mouseWheelMoved(wxMouseEvent &event); void sendDropDownEvent(); + void sendDropDownActionEvent(int index); DECLARE_EVENT_TABLE() diff --git a/src/slic3r/GUI/wxinit.h b/src/slic3r/GUI/wxinit.h deleted file mode 100644 index 4c5ab9fb36..0000000000 --- a/src/slic3r/GUI/wxinit.h +++ /dev/null @@ -1,15 +0,0 @@ -#ifndef slic3r_wxinit_hpp_ -#define slic3r_wxinit_hpp_ - -#include -#include -#include - -// Perl redefines a _ macro, so we undef this one -#undef _ - -// We do want to use translation however, so define it as __ so we can do a find/replace -// later when we no longer need to undef _ -#define __(s) wxGetTranslation((s)) - -#endif diff --git a/src/slic3r/Utils/Profile.hpp b/src/slic3r/Utils/Profile.hpp deleted file mode 100644 index 5fb1e31167..0000000000 --- a/src/slic3r/Utils/Profile.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef slic3r_GUI_Profile_hpp_ -#define slic3r_GUI_Profile_hpp_ - -// Profiling support using the Shiny intrusive profiler -//#define SLIC3R_PROFILE_GUI -#if defined(SLIC3R_PROFILE) && defined(SLIC3R_PROFILE_GUI) - #include - #define SLIC3R_GUI_PROFILE_FUNC() PROFILE_FUNC() - #define SLIC3R_GUI_PROFILE_BLOCK(name) PROFILE_BLOCK(name) - #define SLIC3R_GUI_PROFILE_UPDATE() PROFILE_UPDATE() - #define SLIC3R_GUI_PROFILE_OUTPUT(x) PROFILE_OUTPUT(x) -#else - #define SLIC3R_GUI_PROFILE_FUNC() - #define SLIC3R_GUI_PROFILE_BLOCK(name) - #define SLIC3R_GUI_PROFILE_UPDATE() - #define SLIC3R_GUI_PROFILE_OUTPUT(x) -#endif - -#endif // slic3r_GUI_Profile_hpp_ diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f7ac744e39..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: @@ -2043,3 +2208,40 @@ TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[ CHECK_FALSE(parser.check_3mf_from_prusa(path)); } } + +TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern file paths", "[3mf][Regression]") +{ + Model model; + REQUIRE(load_stl((std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl").c_str(), &model)); + model.add_default_instances(); + ScopedTemporaryDir backup_dir("orca_pick"); + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + + auto plate = std::make_unique(); + plate->plate_index = 0; + plate->pick_file = (backup_dir.path() / "pick.png").string(); + plate->pattern_bbox_file = (backup_dir.path() / "plate.json").string(); + + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.plate_data_list.push_back(plate.get()); + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + boost::nowide::ifstream in(test_file, std::ios::binary); + Model dst_model; + DynamicPrintConfig dst_config; + PlateDataPtrs dst_plates; + Semver file_version; + dst_model.set_backup_path(backup_dir.string()); + REQUIRE(load_gcode_3mf_from_stream(in, &dst_config, &dst_model, &dst_plates, &file_version)); + REQUIRE(dst_plates.size() == 1); + CHECK(dst_plates.front()->pick_file == "Metadata/pick_1.png"); + CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json"); + release_PlateData_list(dst_plates); +} diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 06a08f6f79..80dec7c941 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;