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
Kris Austin 9d71125495 ci: allow running the Main build status workflow manually (#16057) 2026-10-01 18:25:12 -03:00
Kris Austin 88f05d3060 ci: move the nightly Build all to the quietest hour for macOS runners (#16053) 2026-10-01 17:58:00 -03:00
Kris Austin 4743d00793 ci: README build badge no longer shows failing when a queued main build is cancelled (#16054) 2026-10-01 17:56:57 -03:00
7 changed files with 576 additions and 3 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ on:
schedule:
- cron: '0 17 * * *' # run once a day at 1 AM Singapore time (UTC+8)
- cron: '15 2 * * *' # 10:15 AM Singapore time (UTC+8), when macOS runners are least busy
workflow_dispatch: # allows for manual dispatch
inputs:
+28
View File
@@ -0,0 +1,28 @@
# Reports the newest push build of main that was not cancelled, for the README badge.
name: Main build status
on:
workflow_run:
workflows: ["Build all"]
types: [completed]
branches: [main]
workflow_dispatch:
permissions:
actions: read
jobs:
status:
if: github.repository == 'OrcaSlicer/OrcaSlicer'
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
run: |
for attempt in 1 2 3; do
conclusion=$(gh api "repos/${{ github.repository }}/actions/workflows/build_all.yml/runs?branch=main&event=push&status=completed&per_page=100" \
--jq '[.workflow_runs[] | select(.conclusion != "cancelled")][0].conclusion') && break
sleep 10
done
echo "Latest finished push build of main: $conclusion"
[ "$conclusion" = success ]
+1 -1
View File
@@ -6,7 +6,7 @@
<a href="https://trendshift.io/repositories/15552" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15552" alt="OrcaSlicer%2FOrcaSlicer | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub Repo stars](https://img.shields.io/github/stars/OrcaSlicer/OrcaSlicer)](https://github.com/OrcaSlicer/OrcaSlicer/stargazers) [![Build all](https://github.com/OrcaSlicer/OrcaSlicer/actions/workflows/build_all.yml/badge.svg?branch=main)](https://github.com/OrcaSlicer/OrcaSlicer/actions/workflows/build_all.yml)
[![GitHub Repo stars](https://img.shields.io/github/stars/OrcaSlicer/OrcaSlicer)](https://github.com/OrcaSlicer/OrcaSlicer/stargazers) [![Build all](https://img.shields.io/github/actions/workflow/status/OrcaSlicer/OrcaSlicer/main_build_status.yml?label=Build%20all)](https://github.com/OrcaSlicer/OrcaSlicer/actions/workflows/build_all.yml)
OrcaSlicer: an open source Next-Gen Slicing Software for Precision 3D Prints.
Optimize your prints with ultra-fast slicing, intelligent support generation, and seamless printer compatibility—engineered for perfection.
+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.
@@ -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));
}