Address the review on saving texture displacement

Read each layer's texture from the archive only. `layer.path` comes from the project
file, so following it let any .3mf pick a local file to read, a UNC path among them,
and have the next save copy what it found into the archive. Reverts the disk fallback
added in 8ecfee6a2.

Store a paint mask only for the slots a triangle names. Eight empty strings per
triangle cost every project hundreds of megabytes on a large mesh, whether or not it
uses the feature. A slot's run is padded to the triangles seen so far on its first
value, so it stays indexed by triangle, and ends at its last painted one.

Report a malformed layer block instead of throwing out of the project load. The typed
accessors throw on a value of the wrong type and nothing above them caught that, so
one bad number cost the whole file rather than one volume's relief.

Cover the store and load round trip in test_3mf.cpp: the layer, its image, the options
and the paint, the two regressions above, paint at either end of the triangle list, two
volumes sharing a slot, and the wrong-typed documents at the unit level.
This commit is contained in:
ExPikaPaka
2026-10-12 14:20:46 +02:00
parent 30e48183e3
commit 682814878d
4 changed files with 503 additions and 136 deletions
+36 -30
View File
@@ -856,13 +856,30 @@ 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.
// One per texture displacement layer slot, indexed by triangle like the masks above, but
// empty for a slot nothing paints and no longer than its last painted triangle.
// See push_texture_displacement().
std::vector<std::string> texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS];
// BBS
std::vector<std::string> face_properties;
bool empty() { return vertices.empty() || triangles.empty(); }
// Records the paint mask of the triangle just appended, for one layer slot. A slot no
// triangle names keeps its vector empty instead of holding one empty string per triangle:
// eight slots over a million triangles would otherwise cost hundreds of megabytes, for
// every project, whether or not it uses the feature. The vector is padded up to the
// triangles seen so far the first time a value turns up, so it stays indexed by triangle,
// and both readers stop at its size, so trailing triangles without a value need nothing.
void push_texture_displacement(int slot, std::string &&mask_string) {
std::vector<std::string> &mask = texture_displacement[slot];
if (mask_string.empty() && mask.empty())
return;
assert(!triangles.empty());
mask.resize(triangles.size() - 1);
mask.push_back(std::move(mask_string));
}
// backup & restore
void swap(Geometry& o) {
std::swap(vertices, o.vertices);
@@ -3355,34 +3372,23 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
add_error("Malformed texture displacement data: " + json_path);
return;
}
// A layer the archive carries no image for - saved while its texture had not been loaded, or
// written by a build that stored only the path - falls back to the file it came from. The
// importer copies every texture into the user's own library, so that path resolves on the
// machine the project was made on and quietly resolves nowhere else, which is the old
// behaviour: settings restored, pixels not.
const auto load_image_from_disk = [](TextureDisplacementLayer &layer) {
boost::system::error_code ec;
if (layer.path.empty() || !boost::filesystem::is_regular_file(layer.path, ec) || ec)
return;
boost::nowide::ifstream file(layer.path, std::ios::binary);
if (!file.good())
return;
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
if (!bytes.empty())
layer.image_data = std::make_shared<std::vector<unsigned char>>(std::move(bytes));
};
// The pixels come from the archive and nowhere else. A layer the archive carries no image for -
// saved while its texture had not been loaded, or written by a build that stored only the path -
// keeps its settings without an image, which the user recovers by picking the texture again.
// `layer.path` is left alone on purpose: it names a file on the machine the project was made on,
// so reading it here would let any project choose a local file to open - a UNC path among them -
// and have the next save copy it into the archive. The EmbossShape SVGs are read from the archive
// only, for the same reason.
for (TextureDisplacementLayer &layer : layers) {
if (!layer.path_in_3mf.empty()) {
if (const auto image = m_texture_displacement_files.find(layer.path_in_3mf);
image != m_texture_displacement_files.end()) {
layer.image_data = std::make_shared<std::vector<unsigned char>>(image->second.begin(),
image->second.end());
continue;
}
if (layer.path_in_3mf.empty())
continue;
const auto image = m_texture_displacement_files.find(layer.path_in_3mf);
if (image == m_texture_displacement_files.end()) {
add_error("Missing texture displacement image: " + layer.path_in_3mf);
continue;
}
load_image_from_disk(layer);
layer.image_data = std::make_shared<std::vector<unsigned char>>(image->second.begin(),
image->second.end());
}
volume.texture_displacement_layers = std::move(layers);
volume.texture_displacement_options = options;
@@ -4045,8 +4051,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
m_curr_object->geometry.texture_displacement[slot].push_back(
bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
m_curr_object->geometry.push_texture_displacement(
slot, 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));
}
@@ -5917,8 +5923,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
current_object->geometry.texture_displacement[slot].push_back(
bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
current_object->geometry.push_texture_displacement(
slot, 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));
}
+100 -93
View File
@@ -5310,101 +5310,108 @@ bool texture_displacement_layers_from_json(const std::string
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);
}
TextureDisplacementOptions out_options;
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));
// Past the parse, a value of the wrong type still throws: get<int>() on a string, get<float>() on
// null. Nothing between here and the project load catches that, so one bad number would fail the
// whole file instead of this one block. Report it malformed and let the volume load without it.
try {
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);
}
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));
}
}
} catch (...) {
return false;
}
layers = std::move(out_layers);
+326 -13
View File
@@ -28,9 +28,12 @@
#include <miniz.h>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include <memory>
#include <cstdint>
#include <ios>
#include <iterator>
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
@@ -349,6 +352,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf
return replaced;
}
// Writes `model` out as a one-plate project, which is what the application's Save does with it.
static void store_project(const std::string& path, Model& model)
{
ScopedTemporaryDir backup_dir("orca_project_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));
}
// 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 +384,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(path, model);
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
@@ -2080,3 +2089,307 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
release_PlateData_list(dst_plates);
}
// Encodes a flat grayscale PNG the way the texture panel's import does, so what the archive carries
// is a real texture rather than an opaque blob.
static std::shared_ptr<std::vector<unsigned char>> make_gray_png(uint8_t value, size_t w = 4, size_t h = 4)
{
ScopedTemporaryFile file(".png");
const std::vector<uint8_t> pixels(w * h, value);
REQUIRE(png::write_gray_to_file(file.string(), w, h, pixels));
boost::nowide::ifstream in(file.string(), std::ios::binary);
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// Every entry name in a 3mf archive, with the separators normalised the way the importer sees them.
static std::vector<std::string> zip_entry_names(const std::string& path)
{
std::vector<std::string> names;
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_reader(&zip, path));
const mz_uint n = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < n; ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&zip, i, &st) || st.m_is_directory)
continue;
std::string name(st.m_filename);
std::replace(name.begin(), name.end(), '\\', '/');
names.push_back(std::move(name));
}
close_zip_reader(&zip);
return names;
}
static const std::string TEXTURE_DISPLACEMENT_ENTRY_DIR = "Metadata/texture_displacement/";
// Stores a one-plate project holding a cube with one texture displacement layer, painted on that
// layer's own slot. The caller fills the layer in, and gets back the mask string the exporter wrote
// for `painted_facet`, which is what the load has to reproduce.
//
// The painted facet is deliberately not the first one: the importer keeps a slot's masks only as far
// as its last painted triangle, so a facet in the middle is the case where that run has to be padded
// in front and left short at the back.
static void store_texture_displaced_cube(const std::string& path, const TextureDisplacementLayer& layer,
const TextureDisplacementOptions& options, std::string& painted_mask,
int painted_facet = 5)
{
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
volume->texture_displacement_layers.push_back(layer);
volume->texture_displacement_options = options;
REQUIRE(painted_facet < int(volume->mesh().its.indices.size()));
{
TriangleSelector selector(volume->mesh());
selector.set_facet(painted_facet, EnforcerBlockerType::ENFORCER);
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
}
painted_mask = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet);
REQUIRE_FALSE(painted_mask.empty());
store_project(path, model);
}
// Returns the only volume of the only object in the project at `path`, loaded through the importer.
static const ModelVolume& load_single_volume(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
REQUIRE(load_project(path, model, backup_dir));
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 1);
return *model.objects.front()->volumes.front();
}
// A texture displacement stack is the one paint tool whose settings live outside the volume's config:
// the layer stack goes into the archive as its own JSON file, each layer's texture as a PNG beside it,
// and the paint as a per-triangle attribute (see add_texture_displacement). A project only reopens
// with its relief intact if all three come back together.
TEST_CASE("A texture displacement layer, its texture and its paint survive a project round trip", "[3mf][TextureDisplacement]")
{
TextureDisplacementLayer layer;
layer.slot = 3; // not the first slot, so the per-slot attribute names have to line up
layer.name = "Bark";
layer.path = "/textures/bark.png";
layer.depth_mm = 1.25f;
layer.tiling_scale = 7.5f;
layer.midlevel = 0.5f;
layer.invert = true;
layer.tile_method = TextureTileMethod::MirroredRepeat;
layer.projection_method = TextureProjectionMethod::LSCM;
layer.blend_mode = TextureBlendMode::Subtract;
layer.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
layer.image_data = make_gray_png(128);
TextureDisplacementOptions options;
options.smooth_enabled = true;
options.smooth_iterations = 4;
options.active_slot = layer.slot;
options.panel_mode = 1;
const int painted_facet = 5;
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, options, painted, painted_facet);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
REQUIRE(volume.texture_displacement_layers.size() == 1);
const TextureDisplacementLayer& read = volume.texture_displacement_layers.front();
CHECK(read.slot == layer.slot);
CHECK(read.name == layer.name);
CHECK(read.path == layer.path);
CHECK_THAT(read.depth_mm, Catch::Matchers::WithinAbs(layer.depth_mm, 1e-6f));
CHECK_THAT(read.tiling_scale, Catch::Matchers::WithinAbs(layer.tiling_scale, 1e-6f));
CHECK_THAT(read.midlevel, Catch::Matchers::WithinAbs(layer.midlevel, 1e-6f));
CHECK(read.invert == layer.invert);
CHECK(read.tile_method == layer.tile_method);
CHECK(read.projection_method == layer.projection_method);
CHECK(read.blend_mode == layer.blend_mode);
CHECK(read.lscm_seam_edges == layer.lscm_seam_edges);
// The texture rides along in the archive, named after the slot it belongs to, byte for byte.
CHECK(boost::algorithm::starts_with(read.path_in_3mf, TEXTURE_DISPLACEMENT_ENTRY_DIR));
CHECK(boost::algorithm::ends_with(read.path_in_3mf, "_" + std::to_string(layer.slot) + ".png"));
REQUIRE(read.image_data);
CHECK(*read.image_data == *layer.image_data);
CHECK(volume.texture_displacement_options.smooth_enabled);
CHECK(volume.texture_displacement_options.smooth_iterations == options.smooth_iterations);
CHECK(volume.texture_displacement_options.active_slot == options.active_slot);
CHECK(volume.texture_displacement_options.panel_mode == options.panel_mode);
// The paint comes back on the facet it was put on, on its own slot and nowhere else.
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(0).empty());
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
if (slot != layer.slot)
CHECK(volume.texture_displacement_facet(slot).empty());
}
// A layer names the file its texture was imported from, which the library panel uses to point at the
// entry the layer came from. That name must not be opened: the project may come from anyone, the path
// may name any file the user can read - on Windows a UNC path names a host as well - and the next save
// would copy whatever was found there into the archive. A layer the archive has no image for comes
// back without pixels instead, which the user fixes by picking the texture again.
TEST_CASE("A texture displacement layer whose image the archive lacks is not read from the path it names", "[3mf][TextureDisplacement][Regression]")
{
// A file that really is there, so the test fails if the importer ever reaches for it.
ScopedTemporaryFile texture(".png");
REQUIRE(png::write_gray_to_file(texture.string(), 4, 4, std::vector<uint8_t>(16, 200)));
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.path = texture.string(); // and no image_data: nothing for the exporter to put in the archive
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
for (const std::string& name : zip_entry_names(temp.string()))
if (boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR))
CHECK_FALSE(boost::algorithm::ends_with(name, ".png"));
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
REQUIRE(volume.texture_displacement_layers.size() == 1);
CHECK(volume.texture_displacement_layers.front().path == texture.string());
CHECK_FALSE(bool(volume.texture_displacement_layers.front().image_data));
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
}
// The stack's JSON is arbitrary text by the time it is read back, and a value of the wrong type in it
// throws out of the parse. That has to cost the volume its layers, not the project: the model, and the
// paint the user can still build on, load as they would from a build without the feature.
TEST_CASE("A malformed texture displacement block leaves the rest of the project loadable", "[3mf][TextureDisplacement][Regression]")
{
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.image_data = make_gray_png(128);
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
REQUIRE(rewrite_3mf_entries(temp.string(), [](std::string& name, std::string& data) {
if (!boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR) ||
!boost::algorithm::ends_with(name, ".json"))
return false;
// `offset` goes through a typed accessor, so a string in it throws; a plain setting like
// depth_mm would be shrugged off by the parse instead, leaving the layer loadable.
data = R"({"version":1,"layers":[{"slot":0,"offset":["deep","deep"]}]})";
return true;
}));
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);
REQUIRE(model.objects.front()->volumes.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
CHECK(volume.texture_displacement_layers.empty());
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
}
// A slot's masks are kept only as far as its last painted triangle, so where the paint falls in the
// list decides whether that run has to be padded in front of the paint, cut short behind it, or both.
TEST_CASE("Texture displacement paint survives wherever it falls in the triangle list", "[3mf][TextureDisplacement]")
{
const int painted_facet = GENERATE(0, 5, 11); // first, middle and last facet of a cube
INFO("painted facet " << painted_facet);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.image_data = make_gray_png(128);
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted, painted_facet);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
for (int facet = 0; facet < int(volume.mesh().its.indices.size()); ++facet)
if (facet != painted_facet)
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(facet).empty());
}
// Each volume's stack is written under the volume's own id, and the paint of a whole object arrives as
// one run of triangles that the volumes take their slices of. Two volumes using the same slot are the
// case where both of those have to tell the volumes apart.
TEST_CASE("Two volumes of one object keep their own texture displacement layers", "[3mf][TextureDisplacement]")
{
const auto first_image = make_gray_png(64);
const auto second_image = make_gray_png(192);
REQUIRE(*first_image != *second_image);
// The facet each volume is painted on, volume-local, so a mask landing on the wrong volume shows up.
const int painted_facets[2] = { 0, 7 };
std::string painted[2];
ScopedTemporaryFile temp(".3mf");
{
Model model;
ModelObject* object = model.add_object();
object->add_instance();
for (int i = 0; i < 2; ++i) {
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
TextureDisplacementLayer layer;
layer.slot = 0; // the same slot in both, which is what makes their names collide
layer.name = i == 0 ? "First" : "Second";
layer.depth_mm = i == 0 ? 0.5f : 1.5f;
layer.image_data = i == 0 ? first_image : second_image;
volume->texture_displacement_layers.push_back(layer);
TriangleSelector selector(volume->mesh());
selector.set_facet(painted_facets[i], EnforcerBlockerType::ENFORCER);
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
painted[i] = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]);
REQUIRE_FALSE(painted[i].empty());
}
store_project(temp.string(), model);
}
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 2);
const std::shared_ptr<std::vector<unsigned char>> images[2] = { first_image, second_image };
const std::string names[2] = { "First", "Second" };
const float depths[2] = { 0.5f, 1.5f };
std::string entries[2];
for (int i = 0; i < 2; ++i) {
INFO("volume " << i);
const ModelVolume& volume = *model.objects.front()->volumes[i];
REQUIRE(volume.texture_displacement_layers.size() == 1);
const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front();
CHECK(layer.name == names[i]);
CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(depths[i], 1e-6f));
REQUIRE(layer.image_data);
CHECK(*layer.image_data == *images[i]);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]) == painted[i]);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[1 - i]).empty());
entries[i] = layer.path_in_3mf;
}
// Both volumes paint slot 0, so only the volume id keeps their textures apart in the archive.
CHECK(entries[0] != entries[1]);
}
@@ -2300,6 +2300,47 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry",
CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options));
}
// The vectors, the islands and the UV overrides are read through nlohmann's typed accessors, which
// throw on a value of the wrong type rather than returning anything. Nothing between here and the
// project load catches that, so a document carrying one has to come back as "malformed block".
TEST_CASE("Texture displacement JSON reports a value of the wrong type instead of throwing", "[TextureDisplacement]")
{
const std::string json = GENERATE(
R"({"layers":[{"slot":0,"offset":["a","b"]}]})",
R"({"layers":[{"slot":0,"view_project_right":[1,null,3]}]})",
R"({"layers":[{"slot":0,"view_project_matrix":[1,2,3,4,5,6,7,8,9,10,11,"x"]}]})",
R"({"layers":[{"slot":0,"lscm_seam_edges":["a","b"]}]})",
R"({"layers":[{"slot":0,"islands":[[1,2,"a",4]]}]})",
R"({"layers":[{"slot":0,"lscm_uv_overrides":[[1,"a",3]]}]})");
INFO(json);
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
bool parsed = true;
REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(json, layers, options));
CHECK_FALSE(parsed);
CHECK(layers.empty());
}
// A plain setting is read through a helper that shrugs a wrong type off, which is the deliberate
// other half of the case above: the field keeps its default and the rest of the layer still loads.
TEST_CASE("Texture displacement JSON keeps the default for a setting of the wrong type", "[TextureDisplacement]")
{
const std::string json = R"({"options":{"smooth_strength":"a lot"},"layers":[{"slot":"two","name":"Old","depth_mm":0.75}]})";
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
REQUIRE(texture_displacement_layers_from_json(json, layers, options));
REQUIRE(layers.size() == 1);
const TextureDisplacementLayer fresh;
const TextureDisplacementOptions defaults;
CHECK(layers[0].slot == fresh.slot);
CHECK(layers[0].name == "Old");
CHECK_THAT(layers[0].depth_mm, WithinAbs(0.75f, 1e-6f));
CHECK_THAT(options.smooth_strength, WithinAbs(defaults.smooth_strength, 1e-6f));
}
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
{
TextureDisplacementLayer gray;