Compare commits

..
Author SHA1 Message Date
ExPikaPaka fcb1f62bd9 Save texture displacement in the project file
A painted relief did not survive closing the project: nothing about the feature
was written to the .3mf, so the layers, their textures and the paint mask were
all lost on save. The layer struct's cereal save()/load() serve the undo/redo
stack only, which is one session and one binary stream.

Write the layer stack and the per-volume options as JSON in the archive, and
each layer's texture as the image file it was loaded from, both referenced by
path - the same split EmbossShape already makes for its SVG. The image stays
out of the XML deliberately: it is binary and routinely megabytes, and base64
in an attribute would bloat the one file every reader parses just to list the
objects. JSON rather than the cereal stream next to it because that one is
positional and unversioned, which would make every future field a
project-breaking change; an unknown key is ignored and a missing one keeps its
default, so a project written by either side keeps loading.

The paint masks go in as one attribute per layer slot, mirroring paint_color.
Older readers ignore attributes they do not know, so a project written here
still opens in a build without the feature - it loses the relief, which is all
it could have done with it anyway.

The layers are restored while the volume is being built rather than after the
archive is walked: volumes are constructed only once the whole archive has been
read, so a hook placed after the walk ran before any volume existed and
restored nothing.
2026-10-05 08:36:22 +02:00
6 changed files with 587 additions and 143 deletions
+151 -1
View File
@@ -298,6 +298,13 @@ static constexpr const char* CUSTOM_SUPPORTS_ATTR = "paint_supports";
static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin";
static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam";
static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color";
// Texture displacement. One paint mask per layer slot, mirroring paint_color; the layer stack itself is
// a JSON file in the archive, named by the volume metadata key below (see add_texture_displacement()).
static constexpr const char* TEXTURE_DISPLACEMENT_ATTRS[Slic3r::TEXTURE_DISPLACEMENT_MAX_LAYERS] = {
"paint_texture_0", "paint_texture_1", "paint_texture_2", "paint_texture_3",
"paint_texture_4", "paint_texture_5", "paint_texture_6", "paint_texture_7" };
static constexpr const char* TEXTURE_DISPLACEMENT_KEY = "texture_displacement";
static constexpr const char* TEXTURE_DISPLACEMENT_DIR = "Metadata/texture_displacement/";
// BBS
static constexpr const char* FACE_PROPERTY_ATTR = "face_property";
@@ -785,6 +792,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::vector<std::string> custom_seam;
std::vector<std::string> mmu_segmentation;
std::vector<std::string> fuzzy_skin;
// One per texture displacement layer slot, each parallel to `triangles` like the masks above.
std::vector<std::string> texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS];
// BBS
std::vector<std::string> face_properties;
@@ -805,6 +814,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
custom_seam.clear();
mmu_segmentation.clear();
fuzzy_skin.clear();
for (std::vector<std::string> &slot : texture_displacement)
slot.clear();
}
};
@@ -1138,6 +1149,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
/*IdToSlaSupportPointsMap m_sla_support_points;
IdToSlaDrainHolesMap m_sla_drain_holes;*/
PathToEmbossShapeFileMap m_path_to_emboss_shape_files;
// Texture displacement: every file under Metadata/texture_displacement/, by archive path. Volumes
// are built only after the whole archive has been walked, so by the time a volume names its JSON
// every file it could refer to is already in here, whatever order the archive happened to be in.
std::map<std::string, std::string> m_texture_displacement_files;
void _apply_texture_displacement(ModelVolume &volume, const std::string &json_path);
std::string m_curr_metadata_name;
std::string m_curr_characters;
std::string m_name;
@@ -2004,6 +2020,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
else if (_is_svg_shape_file(name)) {
_extract_embossed_svg_shape_file(name, archive, stat);
}
else if (boost::algorithm::istarts_with(name, TEXTURE_DISPLACEMENT_DIR)) {
std::string contents(stat.m_uncomp_size, '\0');
if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, contents.data(), stat.m_uncomp_size, 0))
m_texture_displacement_files.emplace(name, std::move(contents));
else
add_error("Error while reading texture displacement data");
}
else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) {
m_parsing_slice_info = true;
//extract slice info from archive
@@ -3239,6 +3262,41 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
}*/
// Restores one volume's texture displacement stack from the JSON it named. Called while the volume is
// being built, which is after the whole archive has been walked - so every file it can refer to is
// already extracted, and no deferral is needed.
//
// A missing or malformed JSON leaves the volume with no layers but keeps the paint mask it already
// loaded, which is the same state as a project saved by a build without the feature: recoverable by
// picking the texture again rather than a hard failure.
void _BBS_3MF_Importer::_apply_texture_displacement(ModelVolume &volume, const std::string &json_path)
{
const auto json = m_texture_displacement_files.find(json_path);
if (json == m_texture_displacement_files.end()) {
add_error("Missing texture displacement data: " + json_path);
return;
}
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
if (!texture_displacement_layers_from_json(json->second, layers, options)) {
add_error("Malformed texture displacement data: " + json_path);
return;
}
for (TextureDisplacementLayer &layer : layers) {
if (layer.path_in_3mf.empty())
continue;
const auto image = m_texture_displacement_files.find(layer.path_in_3mf);
if (image == m_texture_displacement_files.end()) {
add_error("Missing texture displacement image: " + layer.path_in_3mf);
continue;
}
layer.image_data = std::make_shared<std::vector<unsigned char>>(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<std::string>(stat.m_uncomp_size, '\0');
@@ -3888,6 +3946,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
m_curr_object->geometry.texture_displacement[slot].push_back(
bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
// BBS
m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
}
@@ -5214,6 +5275,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
volume->mmu_segmentation_facets.touch();
volume->fuzzy_skin_facets.shrink_to_fit();
volume->fuzzy_skin_facets.touch();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const std::vector<std::string> &mask = sub_object->geometry.texture_displacement[slot];
if (mask.empty())
continue; // written by a build without the feature
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
facets.reserve(triangles_count);
for (size_t i = 0; i < triangles_count && i < mask.size(); ++i)
if (!mask[i].empty())
facets.set_triangle_from_string(i, mask[i]);
facets.shrink_to_fit();
facets.touch();
}
}
volume->set_type(volume_data->part_type);
@@ -5229,6 +5302,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
for (const Metadata& metadata : volume_data->metadata) {
if (metadata.key == NAME_KEY)
volume->name = metadata.value;
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
_apply_texture_displacement(*volume, metadata.value);
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
//for old format
@@ -5393,12 +5468,28 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
volume->seam_facets.shrink_to_fit();
volume->mmu_segmentation_facets.shrink_to_fit();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const std::vector<std::string> &mask = geometry.texture_displacement[slot];
if (mask.empty())
continue; // written by a build without the feature
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
facets.reserve(triangles_count);
for (size_t i = 0; i < triangles_count; ++i) {
const size_t index = volume_data.first_triangle_id + i;
if (index < mask.size() && !mask[index].empty())
facets.set_triangle_from_string(i, mask[index]);
}
facets.shrink_to_fit();
facets.touch();
}
volume->set_type(volume_data.part_type);
// apply the remaining volume's metadata
for (const Metadata& metadata : volume_data.metadata) {
if (metadata.key == NAME_KEY)
volume->name = metadata.value;
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
_apply_texture_displacement(*volume, metadata.value);
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
//for old format
@@ -5695,6 +5786,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
current_object->geometry.texture_displacement[slot].push_back(
bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
// BBS
current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
}
@@ -6698,6 +6792,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
stream << " <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n";
stream << " <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n";
stream << " <Default Extension=\"png\" ContentType=\"image/png\"/>\n";
stream << " <Default Extension=\"jpg\" ContentType=\"image/jpeg\"/>\n";
stream << " <Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\n";
stream << " <Default Extension=\"json\" ContentType=\"application/json\"/>\n";
stream << " <Default Extension=\"gcode\" ContentType=\"text/x.gcode\"/>\n";
stream << "</Types>";
@@ -7541,6 +7638,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
output_buffer += "\"";
}
// One attribute per texture displacement layer slot. Older readers ignore attributes they do
// not know, so a project written here still opens in a build without the feature - it just
// loses the paint, which is also all it could have done with it.
for (int slot = 0; slot < TEXTURE_DISPLACEMENT_MAX_LAYERS; ++slot) {
const std::string texture_paint = volume->texture_displacement_facet(slot).get_triangle_as_string(i);
if (texture_paint.empty())
continue;
output_buffer += " ";
output_buffer += TEXTURE_DISPLACEMENT_ATTRS[slot];
output_buffer += "=\"";
output_buffer += texture_paint;
output_buffer += "\"";
}
// BBS
if (i < its.properties.size()) {
std::string prop_str = its.properties[i].to_string();
@@ -7990,7 +8101,44 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
return file_path;
}
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
// Writes a volume's texture displacement stack into the archive and points the volume's metadata at it.
// The layer settings go in as JSON and each layer's texture as the image file it was loaded from, both
// referenced by path - the same split EmbossShape makes for its SVG. The image deliberately stays out of
// the XML: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file
// every reader has to parse just to list the objects.
static void add_texture_displacement(std::stringstream &stream, const ModelVolume &volume, mz_zip_archive &archive,
const std::string &id)
{
if (volume.texture_displacement_layers.empty())
return;
// A copy, because path_in_3mf is only meaningful inside the archive being written and the volume
// being exported is const (and may be saved again, elsewhere, with different paths).
std::vector<TextureDisplacementLayer> layers = volume.texture_displacement_layers;
for (TextureDisplacementLayer &layer : layers) {
layer.path_in_3mf.clear();
if (!layer.image_data || layer.image_data->empty())
continue;
std::string ext = boost::filesystem::path(layer.path).extension().string();
boost::to_lower(ext);
if (ext != ".png" && ext != ".jpg" && ext != ".jpeg")
ext = ".png"; // the library ships PNG; anything unrecognised is stored under a type a reader expects
const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ext;
// No deflate: PNG and JPEG are already compressed, so a second pass only costs time.
if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(),
MZ_NO_COMPRESSION))
layer.path_in_3mf = path;
}
const std::string json = texture_displacement_layers_to_json(layers, volume.texture_displacement_options);
const std::string json_path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + ".json";
if (!mz_zip_writer_add_mem(&archive, json_path.c_str(), json.data(), json.size(), MZ_DEFAULT_COMPRESSION))
return;
stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TEXTURE_DISPLACEMENT_KEY << "\" "
<< VALUE_ATTR << "=\"" << xml_escape(json_path) << "\"/>\n";
}
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
{
std::stringstream stream;
// Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back
@@ -8101,6 +8249,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
if (const std::optional<EmbossShape> &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<TextConfiguration> &tc = volume->text_configuration;
tc.has_value())
+242
View File
@@ -14,6 +14,7 @@
#include <mutex>
#include <numeric>
#include <optional>
#include "nlohmann/json.hpp"
#include <queue>
#include <string>
#include <tuple>
@@ -5041,4 +5042,245 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s
return out;
}
// ---------------------------------------------------------------------------------------------
// Project persistence. See the declarations in TextureDisplacement.hpp for why this is JSON rather
// than the cereal stream next to them.
// ---------------------------------------------------------------------------------------------
namespace {
nlohmann::json vec2_to_json(const Vec2f &v) { return nlohmann::json::array({ v.x(), v.y() }); }
nlohmann::json vec3_to_json(const Vec3f &v) { return nlohmann::json::array({ v.x(), v.y(), v.z() }); }
Vec2f vec2_from_json(const nlohmann::json &j, const Vec2f &fallback)
{
if (!j.is_array() || j.size() != 2)
return fallback;
return Vec2f(j[0].get<float>(), j[1].get<float>());
}
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<float>(), j[1].get<float>(), j[2].get<float>());
}
// 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<class T> 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<T>();
} 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<int>();
}
} // namespace
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options)
{
nlohmann::json root;
root["version"] = 1;
nlohmann::json &opt = root["options"];
opt["displace_border"] = options.displace_border;
opt["smooth_enabled"] = options.smooth_enabled;
opt["smooth_strength"] = options.smooth_strength;
opt["smooth_iterations"] = options.smooth_iterations;
opt["smooth_skip_border"] = options.smooth_skip_border;
opt["pipeline_v2"] = options.pipeline_v2;
opt["v2_refine_mm"] = options.v2_refine_mm;
opt["v2_regularize"] = options.v2_regularize;
opt["v2_max_triangles_k"] = options.v2_max_triangles_k;
opt["v2_relocate"] = options.v2_relocate;
opt["v2_flip_edges"] = options.v2_flip_edges;
opt["color_mix_enabled"] = options.color_mix_enabled;
opt["color_mix_mode"] = int(options.color_mix_mode);
opt["color_despeckle"] = options.color_despeckle;
nlohmann::json &arr = root["layers"];
arr = nlohmann::json::array();
for (const TextureDisplacementLayer &l : layers) {
nlohmann::json j;
j["slot"] = l.slot;
j["name"] = l.name;
j["path"] = l.path;
j["path_in_3mf"] = l.path_in_3mf;
j["depth_mm"] = l.depth_mm;
j["tiling_scale"] = l.tiling_scale;
j["rotation_deg"] = l.rotation_deg;
j["offset"] = vec2_to_json(l.offset);
j["invert"] = l.invert;
j["midlevel"] = l.midlevel;
j["smoothing"] = l.smoothing;
j["edge_smoothing"] = l.edge_smoothing;
j["edge_smoothing_amount"] = l.edge_smoothing_amount;
j["auto_connect_islands"] = l.auto_connect_islands;
j["tile_enabled"] = l.tile_enabled;
j["tile_method"] = int(l.tile_method);
j["projection_method"] = int(l.projection_method);
j["blend_mode"] = int(l.blend_mode);
j["color_enabled"] = l.color_enabled;
j["lscm_seam_angle_deg"] = l.lscm_seam_angle_deg;
j["island_padding_mm"] = l.island_padding_mm;
j["view_project_right"] = vec3_to_json(l.view_project_right);
j["view_project_up"] = vec3_to_json(l.view_project_up);
j["view_project_projective"] = l.view_project_projective;
j["view_project_matrix"] = l.view_project_matrix;
// Flat pairs rather than nested arrays: shorter, and the reader can simply ignore a trailing
// odd element instead of having to validate every sub-array.
nlohmann::json &seams = j["lscm_seam_edges"];
seams = nlohmann::json::array();
for (const auto &[a, b] : l.lscm_seam_edges) {
seams.push_back(a);
seams.push_back(b);
}
nlohmann::json &islands = j["islands"];
islands = nlohmann::json::array();
for (const TextureIsland &island : l.islands)
islands.push_back(nlohmann::json::array(
{ island.offset.x(), island.offset.y(), island.rotation_deg, island.scale }));
j["island_groups"] = l.island_groups;
nlohmann::json &overrides = j["lscm_uv_overrides"];
overrides = nlohmann::json::array();
for (const auto &[key, uv] : l.lscm_uv_overrides)
overrides.push_back(nlohmann::json::array({ key, uv.x(), uv.y() }));
arr.push_back(std::move(j));
}
return root.dump();
}
bool texture_displacement_layers_from_json(const std::string &text,
std::vector<TextureDisplacementLayer> &layers,
TextureDisplacementOptions &options)
{
nlohmann::json root;
try {
root = nlohmann::json::parse(text);
} catch (...) {
return false;
}
if (!root.is_object())
return false;
TextureDisplacementOptions out_options;
if (const auto it = root.find("options"); it != root.end() && it->is_object()) {
const nlohmann::json &opt = *it;
read(opt, "displace_border", out_options.displace_border);
read(opt, "smooth_enabled", out_options.smooth_enabled);
read(opt, "smooth_strength", out_options.smooth_strength);
read(opt, "smooth_iterations", out_options.smooth_iterations);
read(opt, "smooth_skip_border", out_options.smooth_skip_border);
read(opt, "pipeline_v2", out_options.pipeline_v2);
read(opt, "v2_refine_mm", out_options.v2_refine_mm);
read(opt, "v2_regularize", out_options.v2_regularize);
read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k);
read(opt, "v2_relocate", out_options.v2_relocate);
read(opt, "v2_flip_edges", out_options.v2_flip_edges);
read(opt, "color_mix_enabled", out_options.color_mix_enabled);
read(opt, "color_despeckle", out_options.color_despeckle);
int mix_mode = int(out_options.color_mix_mode);
read_enum(opt, "color_mix_mode", mix_mode);
out_options.color_mix_mode = ColorMixMode(std::clamp(mix_mode, 0, 2));
}
std::vector<TextureDisplacementLayer> out_layers;
if (const auto it = root.find("layers"); it != root.end() && it->is_array()) {
for (const nlohmann::json &j : *it) {
if (!j.is_object())
continue;
TextureDisplacementLayer l;
read(j, "slot", l.slot);
read(j, "name", l.name);
read(j, "path", l.path);
read(j, "path_in_3mf", l.path_in_3mf);
read(j, "depth_mm", l.depth_mm);
read(j, "tiling_scale", l.tiling_scale);
read(j, "rotation_deg", l.rotation_deg);
if (const auto o = j.find("offset"); o != j.end())
l.offset = vec2_from_json(*o, l.offset);
read(j, "invert", l.invert);
read(j, "midlevel", l.midlevel);
read(j, "smoothing", l.smoothing);
read(j, "edge_smoothing", l.edge_smoothing);
read(j, "edge_smoothing_amount", l.edge_smoothing_amount);
read(j, "auto_connect_islands", l.auto_connect_islands);
read(j, "tile_enabled", l.tile_enabled);
read(j, "color_enabled", l.color_enabled);
int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode);
read_enum(j, "tile_method", tile_method);
read_enum(j, "projection_method", projection);
read_enum(j, "blend_mode", blend);
l.tile_method = TextureTileMethod(tile_method);
l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4));
l.blend_mode = TextureBlendMode(blend);
read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg);
read(j, "island_padding_mm", l.island_padding_mm);
if (const auto v = j.find("view_project_right"); v != j.end())
l.view_project_right = vec3_from_json(*v, l.view_project_right);
if (const auto v = j.find("view_project_up"); v != j.end())
l.view_project_up = vec3_from_json(*v, l.view_project_up);
read(j, "view_project_projective", l.view_project_projective);
if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12)
for (size_t i = 0; i < 12; ++i)
l.view_project_matrix[i] = (*v)[i].get<float>();
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<int>(), (*v)[i + 1].get<int>());
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<float>(), e[1].get<float>());
island.rotation_deg = e[2].get<float>();
island.scale = e[3].get<float>();
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<int>(), Vec2f(e[1].get<float>(), e[2].get<float>()));
}
out_layers.push_back(std::move(l));
}
}
layers = std::move(out_layers);
options = out_options;
return true;
}
} // namespace Slic3r
+17
View File
@@ -420,6 +420,23 @@ struct TextureDisplacementOptions
}
};
// Project persistence (see bbs_3mf.cpp). The layer stack and the per-volume options are written to the
// .3mf as JSON rather than through the cereal save()/load() above: those two are positional and
// unversioned, which is right for the undo/redo stack they serve (one session, one binary) but would
// make every future field a project-breaking change. A JSON object tolerates both directions - an
// unknown key is ignored, a missing one keeps the member's default - so old projects keep loading and
// new ones degrade gracefully in older builds.
//
// The texture image itself is *not* in here. It is a binary blob that belongs in the archive as a file
// of its own, exactly as EmbossShape stores its SVG; `path_in_3mf` names that file, and the caller is
// responsible for writing it and for filling `image_data` back in on load.
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options);
// Returns false and leaves both outputs untouched when the text is not valid JSON.
bool texture_displacement_layers_from_json(const std::string &text,
std::vector<TextureDisplacementLayer> &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.
@@ -489,15 +489,7 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
// volume for a shaded pass that then draws nothing is what made the model vanish - most obviously
// with zero layers, but equally with a layer that has no texture picked yet.
const bool use_shaded = m_use_shaded_preview && m_shaded_preview_glmodel.is_initialized() && shaded_preview_ready();
// Checker/Distortion are built from the *base* patch and drawn with a polygon offset, which biases
// depth values - it does not move the geometry. It therefore cannot win against a surface that
// genuinely stands in front, and the displaced preview does exactly that: it rises above the base
// surface by the layer's depth. Drawn underneath a UV-check overlay it simply occludes it, which is
// why those two modes looked like they did nothing. Leave it out and let the undisplaced volume show
// through instead (toggle_model_objects_visibility below) - that one *is* coincident with the
// overlay, which is what the offset assumes, and it is the surface whose mapping is being inspected.
const bool use_true_preview = !use_shaded && m_uv_check_mode == UVCheckMode::None &&
m_preview_glmodel.is_initialized();
const bool use_true_preview = !use_shaded && m_preview_glmodel.is_initialized();
// In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is
// looking at, so the opaque paint-selection highlight must not be drawn on top of it - same
// reasoning as skipping it for the shaded preview (see bug #12). Without this the painted area
@@ -525,13 +517,7 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
render_triangles(selection);
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
}
} else {
// render_triangles() *is* the model in a painter gizmo (it draws every model-part volume with the
// selector's colours), not an overlay on top of one - so it still has to run under a UV-check
// overlay, or nothing draws the surface at all and the checker floats alone over an empty scene.
// Deliberately without the depth bias the branch above applies: the checker/heatmap is drawn later
// with its own -1 offset and has to win against this. Biasing both by the same amount is what made
// the painted area cover the checker and is why this call used to be skipped outright.
} else if (show_paint_overlay) {
render_triangles(selection);
}
@@ -5724,20 +5710,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const int cur_mode = m_use_shaded_preview ? 1 :
m_uv_check_mode == UVCheckMode::Checker ? 2 :
m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0;
int new_mode = cur_mode;
bool wf_toggle = false;
bool open_uv_editor = false;
// Checker and Distortion both draw *the unwrap* - the first the texture grid laid over it, the second
// its stretch - so they only mean anything for a layer mapped with Unwrap (LSCM). On the default
// triplanar mapping (or cylindrical / spherical / from view) they are faded out with the reason in the
// tooltip, rather than being offered and then showing nothing.
const wxString uv_view_na = active == nullptr ? _L("Add a layer first.") :
active->projection_method != TextureProjectionMethod::LSCM ?
_L("Only for a layer mapped with Unwrap (LSCM) - set the "
"active layer's Mapping to Unwrap to use this view.") :
wxString();
// Either view over a layer that stopped being an unwrap shows nothing at all, so fall back to Normal.
if ((cur_mode == 2 || cur_mode == 3) && !uv_view_na.empty())
int new_mode = cur_mode;
bool wf_toggle = false;
const wxString distortion_na = active == nullptr ? _L("Add a layer first.") :
active->projection_method != TextureProjectionMethod::LSCM ?
_L("Needs the active layer mapped with Unwrap (LSCM).") :
wxString();
// Distortion over a layer that stopped being an unwrap shows nothing at all, so fall back to Normal.
if (cur_mode == 3 && !distortion_na.empty())
new_mode = 0;
const float x0 = ImGui::GetCursorPosX();
@@ -5756,20 +5736,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"),
_L("Checker - a test grid instead of the texture. Where the squares stay square the "
"texture is undistorted; where they stretch, it will too. Opens the UV editor if "
"it is closed"),
uv_view_na)) {
new_mode = 2;
open_uv_editor = true;
}
"texture is undistorted; where they stretch, it will too")))
new_mode = 2;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"),
_L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if "
"it is closed"),
uv_view_na)) {
new_mode = 3;
open_uv_editor = true;
}
_L("Distortion - blue-to-red stretch heatmap over the unwrap"), distortion_na))
new_mode = 3;
vsep(icon_md);
if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"),
_L("Wireframe - overlay the mesh edges; independent of the view above")))
@@ -5784,13 +5756,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Rebuilds the preview as soon as anything changes. Turn it off on a heavy model if painting "
"or dragging a slider starts to stutter - the preview then waits until you let go."));
// Both are views of the unwrap, so picking one brings the UV editor up with it - including when that
// view is already the active one and only the pane is missing.
if (open_uv_editor && !m_show_uv_editor) {
m_show_uv_editor = true;
if (new_mode == cur_mode)
update_uv_editor(); // otherwise apply_view_mode() below does it
}
if (new_mode != cur_mode)
apply_view_mode(new_mode);
if (wf_toggle) {
+27 -93
View File
@@ -1712,7 +1712,7 @@ public:
bool toggle, bool accent = false, int size_dip = 26)
: wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE | wxFULL_REPAINT_ON_RESIZE)
, m_icon_name(icon), m_icon_dip(size_dip >= 26 ? 16 : 14), m_label(label), m_toggle(toggle), m_accent(accent)
, m_size_dip(size_dip), m_tip(tip)
, m_size_dip(size_dip)
{
SetBackgroundStyle(wxBG_STYLE_PAINT);
SetToolTip(tip);
@@ -1725,7 +1725,7 @@ public:
Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent &) { m_hover = true; Refresh(); });
Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent &) { m_hover = false; m_pressed = false; Refresh(); });
Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent &) {
if (usable()) {
if (IsEnabled()) {
m_pressed = true;
Refresh();
}
@@ -1734,7 +1734,7 @@ public:
const bool was_pressed = m_pressed;
m_pressed = false;
Refresh();
if (!was_pressed || !usable() || !GetClientRect().Contains(e.GetPosition()))
if (!was_pressed || !IsEnabled() || !GetClientRect().Contains(e.GetPosition()))
return;
if (m_toggle)
m_on = !m_on;
@@ -1774,30 +1774,6 @@ public:
Refresh();
return changed;
}
// Soft-disable: the button is drawn faded and swallows clicks, but stays a live window, so hovering it
// still raises its tooltip - now with `reason` appended, saying what to do to make it usable. A window
// really disabled with Enable(false) gets no mouse events at all on GTK and MSW, which leaves the user
// guessing; this is the same trade-off the gizmo panel's icon_toggle() makes with its `unavailable`.
// An empty reason makes the button usable again.
void SetUnavailable(const wxString &reason)
{
if (reason == m_unavailable)
return;
m_unavailable = reason;
SetToolTip(reason.empty() || m_tip.empty() ? m_tip : m_tip + "\n\n" + reason);
if (!m_unavailable.empty())
m_pressed = false; // a reason appearing mid-press cancels the press
Refresh();
}
// Replaces the plain tooltip, keeping whatever reason is currently appended to it.
void SetTip(const wxString &tip)
{
if (tip == m_tip)
return;
m_tip = tip;
SetToolTip(m_unavailable.empty() || m_tip.empty() ? m_tip : m_tip + "\n\n" + m_unavailable);
}
bool usable() const { return IsEnabled() && m_unavailable.empty(); }
protected:
wxSize DoGetBestSize() const override
@@ -1816,7 +1792,7 @@ private:
const PaneColors c = PaneColors::current();
const wxRect r = GetClientRect();
const wxColour teal(0x00, 0x96, 0x88);
const bool enabled = usable();
const bool enabled = IsEnabled();
wxColour fill = c.bg, border = c.frame, text = c.ink;
if (m_accent) {
@@ -1876,8 +1852,6 @@ private:
bool m_toggle = false;
bool m_accent = false;
int m_size_dip = 26;
wxString m_tip; // the tooltip without any m_unavailable reason appended
wxString m_unavailable; // non-empty: faded and unclickable, and why (see SetUnavailable())
bool m_on = false;
bool m_badge = false;
bool m_hover = false;
@@ -1923,9 +1897,6 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
});
m_layer_name->SetMinSize(wxSize(FromDIP(30), -1));
m_tile = text(wxEmptyString, c.dim);
m_tile->SetToolTip(_L("The active layer's tile size: how much of the model one repeat of the texture covers. The "
"canvas is measured in tiles, so one grid cell is one repeat. Change it with Tiling in the "
"layer's settings."));
header->Add(m_thumb, 0, wxALIGN_CENTER_VERTICAL);
header->Add(m_layer_name, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, gap);
header->Add(m_tile, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, gap);
@@ -1979,19 +1950,9 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
strip->Add(r, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(11));
strip->AddSpacer(FromDIP(7));
};
m_select[0] = tool(ID_UV_SELECT_ISLAND, "texture_displacement_uv_select_island",
_L("Island - work on whole islands. Click one to select it, then drag to move it, right-drag to "
"rotate it, or press R to rotate and S to scale with the mouse (click or Enter to confirm, Esc "
"to cancel)."),
true);
m_select[1] = tool(ID_UV_SELECT_VERTEX, "texture_displacement_uv_select_vertex",
_L("Vertex - drag vertices to reshape an island by hand; Shift adds to the selection, Ctrl "
"toggles one in or out of it."),
true);
m_select[2] = tool(ID_UV_SELECT_EDGE, "texture_displacement_uv_select_edge",
_L("Edge - drag edges to reshape an island by hand; Shift adds to the selection, Ctrl toggles "
"one in or out of it."),
true);
m_select[0] = tool(ID_UV_SELECT_ISLAND, "texture_displacement_uv_select_island", _L("Island - move, rotate and scale whole islands"), true);
m_select[1] = tool(ID_UV_SELECT_VERTEX, "texture_displacement_uv_select_vertex", _L("Vertex - drag vertices to reshape; Shift/Ctrl to multi-select"), true);
m_select[2] = tool(ID_UV_SELECT_EDGE, "texture_displacement_uv_select_edge", _L("Edge - drag edges to reshape; Shift/Ctrl to multi-select"), true);
strip_rule();
m_mark_seams = tool(ID_UV_MARK_SEAMS, "texture_displacement_uv_seam",
_L("Mark seams - click edges on the model to cut the unwrap along them. The edge under the cursor is "
@@ -2012,11 +1973,7 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
m_clear_edits = tool(ID_UV_CLEAR_EDITS, "texture_displacement_uv_clear_edits",
_L("Clear UV edits - discard all manual vertex/edge moves and return the unwrap to its automatic shape"), false);
m_snap = tool(ID_UV_SNAP, "texture_displacement_uv_snap", _L("Snap - stick islands together when dragging one against another"), true);
m_frame = tool(ID_UV_FRAME, "texture_displacement_uv_frame",
_L("Frame all islands, fitting every one of them in view (Home or F).\n"
"Elsewhere on the canvas: scroll to zoom around the cursor, and middle-drag - or drag empty space - "
"to pan."),
false);
m_frame = tool(ID_UV_FRAME, "texture_displacement_uv_frame", _L("Frame all islands (Home)"), false);
strip->AddSpacer(FromDIP(4));
m_canvas = new UVEditorCanvas(this);
@@ -2027,7 +1984,6 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
// ---- status line: the current gesture on the left, the unwrap summary on the right ----
m_status = text(wxEmptyString, c.dim, wxST_ELLIPSIZE_END);
m_status->SetToolTip(_L("What is selected, and the exact figures of the move, rotation or scale while you drag one."));
m_status->SetMinSize(wxSize(FromDIP(40), -1));
m_stats = text(wxEmptyString, c.dim);
auto *status = new wxBoxSizer(wxHORIZONTAL);
@@ -2103,25 +2059,19 @@ void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s)
} else {
m_thumb->SetBitmap(wxNullBitmap);
}
// Every tool that cannot be used right now is faded with the reason appended to its tooltip, rather than
// being hard-disabled (which would hide the tooltip too - see UVToolButton::SetUnavailable()).
const wxString no_layer = s.has_layer ? wxString() :
_L("The pane follows the active texture layer, and that layer has to be mapped "
"with Unwrap (LSCM). Add a layer and set its Mapping to Unwrap.");
m_thumb->SetUnavailable(no_layer);
m_layer_name->SetToolTip(s.has_layer ? m_thumb->GetToolTipText() : no_layer);
m_thumb->Enable(s.has_layer);
for (int i = 0; i < 3; ++i) {
m_background[i]->SetValue(int(s.background) == i);
m_background[i]->SetUnavailable(no_layer);
m_background[i]->Enable(s.has_layer);
}
m_unwrap->SetUnavailable(no_layer);
m_unwrap->Enable(s.has_layer);
m_unwrap->SetBadge(s.unwrap_stale);
m_unwrap->SetTip(s.unwrap_stale ?
_L("Out of date - the paint, the seams or the seam angle changed since this unwrap was made. "
"Press to unwrap again.") :
_L("Flatten the painted area into UV islands. It is computed only when you press this, not on "
"every edit - so paint, change the seam angle or mark seams first, then press Unwrap."));
m_unwrap->SetToolTip(s.unwrap_stale ?
_L("Out of date - the paint, the seams or the seam angle changed since this unwrap was made. "
"Press to unwrap again.") :
_L("Flatten the painted area into UV islands. It is computed only when you press this, not on "
"every edit - so paint, change the seam angle or mark seams first, then press Unwrap."));
if (m_seam_angle->GetValue() != int(std::lround(s.seam_angle_deg)))
m_seam_angle->SetValue(int(std::lround(s.seam_angle_deg)));
@@ -2130,26 +2080,15 @@ void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s)
m_connect->Enable(s.has_layer);
m_mark_seams->SetValue(s.mark_seams);
m_mark_seams->SetUnavailable(no_layer);
m_mark_seams->Enable(s.has_layer);
m_seam_path->SetValue(s.seam_path);
m_seam_path->SetUnavailable(!no_layer.empty() ? no_layer :
s.mark_seams ? wxString() :
_L("Turn Mark seams on first - Path is a quicker way of marking them."));
m_clear_seams->SetUnavailable(!no_layer.empty() ? no_layer :
s.has_seams ? wxString() :
_L("No seams are marked on this layer."));
m_clear_edits->SetUnavailable(!no_layer.empty() ? no_layer :
s.has_uv_edits ? wxString() :
_L("No islands have been reshaped by hand, so there is nothing to "
"discard."));
m_seam_path->Enable(s.has_layer && s.mark_seams);
m_clear_seams->Enable(s.has_layer && s.has_seams);
m_clear_edits->Enable(s.has_layer && s.has_uv_edits);
m_stats->SetLabel(s.unwrapped ? wxString::Format(_L("%d islands, %s faces"), s.island_count,
wxString(std::to_string(s.face_count))) :
wxString());
m_stats->SetToolTip(s.unwrapped ? _L("How the painted area came out of the unwrap: the number of separate pieces it "
"was cut into (at the seams and at edges sharper than the seam angle), and how "
"many triangles they hold in total.") :
wxString());
refresh_selection_tools();
if (relayout)
Layout();
@@ -2159,24 +2098,19 @@ void UVEditorPanel::refresh_selection_tools()
{
const bool has_islands = m_canvas->has_islands();
const int mode = int(m_canvas->select_mode());
// Faded rather than hard-disabled, so the tooltip still says what is missing (see apply_state()).
const wxString not_unwrapped = has_islands ? wxString() : _L("Press Unwrap first - there are no islands to work on yet.");
for (int i = 0; i < 3; ++i) {
m_select[i]->SetValue(i == mode);
m_select[i]->SetUnavailable(not_unwrapped);
m_select[i]->Enable(has_islands);
}
const bool island_picked = has_islands && m_canvas->select_mode() == UVEditorCanvas::SelectMode::Island &&
m_canvas->selected_island() >= 0;
const wxString no_island = !not_unwrapped.empty() ? not_unwrapped :
island_picked ? wxString() :
_L("Click an island on the canvas first, in Island mode.");
m_avg_scale->SetUnavailable(not_unwrapped);
m_cut->SetUnavailable(no_island);
m_join->SetUnavailable(no_island);
m_unjoin->SetUnavailable(no_island);
m_snap->SetUnavailable(not_unwrapped);
m_avg_scale->Enable(has_islands);
m_cut->Enable(island_picked);
m_join->Enable(island_picked);
m_unjoin->Enable(island_picked);
m_snap->Enable(has_islands);
m_snap->SetValue(m_canvas->snap_enabled());
m_frame->SetUnavailable(not_unwrapped);
m_frame->Enable(has_islands);
}
void UVEditorPanel::on_tool(wxCommandEvent &evt)
@@ -2112,3 +2112,139 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T
CHECK(second <= single * 5 / 4);
}
// The .3mf stores the layer stack as JSON (see texture_displacement_layers_to_json). Two properties
// matter: everything the UV editor and the panel can set has to survive a round trip, and a document
// written by a build that knew fewer fields has to keep loading, with the missing ones left at their
// defaults rather than zeroed.
TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisplacement]")
{
std::vector<TextureDisplacementLayer> layers(2);
TextureDisplacementLayer &a = layers[0];
a.slot = 0;
a.name = "Bark";
a.path = "/textures/bark.png";
a.depth_mm = 1.25f;
a.tiling_scale = 7.5f;
a.rotation_deg = 30.f;
a.offset = Vec2f(0.25f, -0.5f);
a.invert = true;
a.midlevel = 0.125f;
a.smoothing = 0.75f;
a.edge_smoothing = true;
a.edge_smoothing_amount = 0.25f;
a.auto_connect_islands = false;
a.tile_enabled = false;
a.tile_method = TextureTileMethod::Mirror;
a.projection_method = TextureProjectionMethod::LSCM;
a.blend_mode = TextureBlendMode::Subtract;
a.color_enabled = true;
a.lscm_seam_angle_deg = 45.f;
a.island_padding_mm = 0.5f;
a.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
a.islands = { TextureIsland{ Vec2f(1.f, 2.f), 15.f, 1.5f }, TextureIsland{} };
a.island_groups = { 0, 0 };
a.lscm_uv_overrides = { { -4, Vec2f(0.5f, 0.75f) } };
TextureDisplacementLayer &b = layers[1];
b.slot = 1;
b.projection_method = TextureProjectionMethod::ViewProjected;
b.view_project_right = Vec3f(0.f, 1.f, 0.f);
b.view_project_up = Vec3f(0.f, 0.f, 1.f);
b.view_project_projective = true;
for (size_t i = 0; i < b.view_project_matrix.size(); ++i)
b.view_project_matrix[i] = float(i) + 0.5f;
TextureDisplacementOptions options;
options.displace_border = false;
options.smooth_enabled = true;
options.smooth_strength = 0.6f;
options.smooth_iterations = 5;
options.pipeline_v2 = false;
options.v2_max_triangles_k = 250;
options.color_mix_mode = ColorMixMode::XYDither;
options.color_despeckle = 4;
const std::string json = texture_displacement_layers_to_json(layers, options);
std::vector<TextureDisplacementLayer> read_layers;
TextureDisplacementOptions read_options;
REQUIRE(texture_displacement_layers_from_json(json, read_layers, read_options));
REQUIRE(read_layers.size() == layers.size());
const TextureDisplacementLayer &ra = read_layers[0];
CHECK(ra.name == a.name);
CHECK(ra.path == a.path);
CHECK(ra.depth_mm == Approx(a.depth_mm));
CHECK(ra.tiling_scale == Approx(a.tiling_scale));
CHECK(ra.rotation_deg == Approx(a.rotation_deg));
CHECK(ra.offset.isApprox(a.offset));
CHECK(ra.invert == a.invert);
CHECK(ra.midlevel == Approx(a.midlevel));
CHECK(ra.smoothing == Approx(a.smoothing));
CHECK(ra.edge_smoothing == a.edge_smoothing);
CHECK(ra.edge_smoothing_amount == Approx(a.edge_smoothing_amount));
CHECK(ra.auto_connect_islands == a.auto_connect_islands);
CHECK(ra.tile_enabled == a.tile_enabled);
CHECK(ra.tile_method == a.tile_method);
CHECK(ra.projection_method == a.projection_method);
CHECK(ra.blend_mode == a.blend_mode);
CHECK(ra.color_enabled == a.color_enabled);
CHECK(ra.lscm_seam_angle_deg == Approx(a.lscm_seam_angle_deg));
CHECK(ra.island_padding_mm == Approx(a.island_padding_mm));
CHECK(ra.lscm_seam_edges == a.lscm_seam_edges);
REQUIRE(ra.islands.size() == a.islands.size());
CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset));
CHECK(ra.islands[0].rotation_deg == Approx(a.islands[0].rotation_deg));
CHECK(ra.islands[0].scale == Approx(a.islands[0].scale));
CHECK(ra.island_groups == a.island_groups);
REQUIRE(ra.lscm_uv_overrides.size() == 1);
CHECK(ra.lscm_uv_overrides[0].first == -4);
CHECK(ra.lscm_uv_overrides[0].second.isApprox(Vec2f(0.5f, 0.75f)));
const TextureDisplacementLayer &rb = read_layers[1];
CHECK(rb.projection_method == TextureProjectionMethod::ViewProjected);
CHECK(rb.view_project_right.isApprox(b.view_project_right));
CHECK(rb.view_project_up.isApprox(b.view_project_up));
CHECK(rb.view_project_projective);
CHECK(rb.view_project_matrix == b.view_project_matrix);
CHECK(read_options.displace_border == options.displace_border);
CHECK(read_options.smooth_enabled == options.smooth_enabled);
CHECK(read_options.smooth_strength == Approx(options.smooth_strength));
CHECK(read_options.smooth_iterations == options.smooth_iterations);
CHECK(read_options.pipeline_v2 == options.pipeline_v2);
CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k);
CHECK(read_options.color_mix_mode == options.color_mix_mode);
CHECK(read_options.color_despeckle == options.color_despeckle);
}
TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]")
{
// What a build that knew fewer fields would have written: one layer, almost nothing set.
const std::string sparse = R"({"version":1,"layers":[{"slot":2,"name":"Old","depth_mm":0.75}]})";
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
REQUIRE(texture_displacement_layers_from_json(sparse, layers, options));
REQUIRE(layers.size() == 1);
const TextureDisplacementLayer &l = layers[0];
const TextureDisplacementLayer fresh;
const TextureDisplacementOptions defaults;
CHECK(l.slot == 2);
CHECK(l.name == "Old");
CHECK(l.depth_mm == Approx(0.75f));
// Everything absent keeps the struct's own default rather than becoming zero.
CHECK(l.tiling_scale == Approx(fresh.tiling_scale));
CHECK(l.auto_connect_islands == fresh.auto_connect_islands);
CHECK(l.tile_enabled == fresh.tile_enabled);
CHECK(l.projection_method == fresh.projection_method);
CHECK(l.lscm_seam_angle_deg == Approx(fresh.lscm_seam_angle_deg));
CHECK(options.color_mix_mode == defaults.color_mix_mode);
CHECK(options.pipeline_v2 == defaults.pipeline_v2);
std::vector<TextureDisplacementLayer> unused_layers;
TextureDisplacementOptions unused_options;
CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options));
}