diff --git a/src/dev-utils/CMakeLists.txt b/src/dev-utils/CMakeLists.txt index e718da9e83..19fa1c3ce3 100644 --- a/src/dev-utils/CMakeLists.txt +++ b/src/dev-utils/CMakeLists.txt @@ -28,6 +28,12 @@ if (ORCA_TOOLS) target_link_libraries(generate_system_cache libslic3r boost_headeronly) target_compile_definitions(generate_system_cache PRIVATE ${_DEV_DEFS}) + # texture_unwrap_dump: reports the LSCM unwrap of a saved project's texture displacement layers, + # chart by chart, so a defect can be reproduced from the project file instead of from a screenshot. + add_executable(texture_unwrap_dump texture_unwrap_dump.cpp) + target_link_libraries(texture_unwrap_dump libslic3r boost_headeronly nanosvg) + target_compile_definitions(texture_unwrap_dump PRIVATE ${_DEV_DEFS}) + # profile_include_dump: prints what included templates contribute to a vendor's presets, # to diff against the same tool built in BambuStudio. Built only on request. add_executable(profile_include_dump EXCLUDE_FROM_ALL profile_include_dump.cpp) diff --git a/src/dev-utils/texture_unwrap_dump.cpp b/src/dev-utils/texture_unwrap_dump.cpp new file mode 100644 index 0000000000..5c94f4b12b --- /dev/null +++ b/src/dev-utils/texture_unwrap_dump.cpp @@ -0,0 +1,292 @@ +// Diagnostic for the LSCM unwrap of a texture displacement layer. +// +// It exists because the defect it hunts only shows up on a real painted patch: the paint mask is built +// by TriangleSelector splitting base triangles, so the patch topology cannot be written down by hand, +// and reasoning about it from a screenshot of the 3D view had already produced three wrong diagnoses. +// This loads a saved project, rebuilds exactly the patch the bake would act on, runs the same unwrap, +// and reports what came out - per chart, so a bad one can be pointed at rather than guessed at. +// +// texture_unwrap_dump + +// nanosvg is header-only and libslic3r's 3mf import references it without carrying the implementation, +// so every executable that links libslic3r has to supply it. Must precede any include that pulls the +// header in, or its include guard suppresses the implementation. Same pattern as the other dev tools. +#define NANOSVG_IMPLEMENTATION +#include "nanosvg/nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "nanosvg/nanosvgrast.h" + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" +#include "libslic3r/Utils.hpp" + +#include + +using namespace Slic3r; + +namespace { + +uint64_t edge_key(int a, int b) +{ + if (a > b) + std::swap(a, b); + return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); +} + +// Boundary loops and the Euler characteristic of a face set, which together say whether a chart is the +// topological disk LSCM needs (one loop, V - E + F == 1). +void chart_topology(const indexed_triangle_set &mesh, const std::vector &faces, int &loops, int &euler) +{ + std::unordered_map edge_use; + std::unordered_map local; + for (const int f : faces) { + const stl_triangle_vertex_indices &t = mesh.indices[size_t(f)]; + for (int i = 0; i < 3; ++i) { + ++edge_use[edge_key(t[i], t[(i + 1) % 3])]; + local.emplace(t[i], int(local.size())); + } + } + euler = int(local.size()) - int(edge_use.size()) + int(faces.size()); + + std::unordered_map parent; + const std::function find = [&](int x) { + while (parent[x] != x) + x = parent[x] = parent[parent[x]]; + return x; + }; + for (const auto &[key, uses] : edge_use) + if (uses == 1) + for (const int v : { int(key >> 32), int(uint32_t(key)) }) + parent.emplace(v, v); + for (const auto &[key, uses] : edge_use) + if (uses == 1) { + const int a = find(int(key >> 32)), b = find(int(uint32_t(key))); + if (a != b) + parent[b] = a; + } + std::unordered_map roots; + for (const auto &[v, p] : parent) + roots[find(v)] = 1; + loops = int(roots.size()); +} + +float signed_area_2d(const Vec2f &a, const Vec2f &b, const Vec2f &c) +{ + return 0.5f * ((b.x() - a.x()) * (c.y() - a.y()) - (c.x() - a.x()) * (b.y() - a.y())); +} + +} // namespace + +int main(int argc, char **argv) +{ + if (argc < 2) { + std::printf("usage: texture_unwrap_dump \n"); + return 2; + } + + Model model; + DynamicPrintConfig config; + ConfigSubstitutionContext ctx(ForwardCompatibilitySubstitutionRule::Enable); + PlateDataPtrs plate_data; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + // The importer writes a backup copy under the data dir and silently loses objects without one. + const boost::filesystem::path tmp = boost::filesystem::temp_directory_path() / "texture_unwrap_dump"; + boost::filesystem::create_directories(tmp); + set_data_dir(tmp.string()); + + // LoadModel so the meshes come through; AddDefaultInstances because an object with no instance is + // dropped by the plate mapping, which is what "skip this object" in the log means. + if (!load_bbs_3mf(argv[1], &config, &ctx, &model, &plate_data, &project_presets, &is_bbl_3mf, &is_orca_3mf, + &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig | LoadStrategy::AddDefaultInstances | + LoadStrategy::Silence)) { + std::printf("failed to load %s\n", argv[1]); + return 1; + } + + std::printf("loaded: %zu object(s)\n", model.objects.size()); + + for (const ModelObject *object : model.objects) + for (const ModelVolume *volume : object->volumes) { + if (volume->texture_displacement_layers.empty()) { + std::printf("volume \"%s\": no texture displacement layers; paint masks per slot:", + volume->name.c_str()); + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + std::printf(" %zu", volume->texture_displacement_facet(i).get_data().triangles_to_split.size()); + std::printf("\n"); + continue; + } + std::printf("volume \"%s\": %zu base triangles, %zu layer(s)\n", volume->name.c_str(), + volume->mesh().its.indices.size(), volume->texture_displacement_layers.size()); + + for (const TextureDisplacementLayer &layer : volume->texture_displacement_layers) { + std::printf("\n layer %d \"%s\" mapping=%d seam_angle=%.1f connect=%d islands_stored=%zu\n", + layer.slot, layer.name.c_str(), int(layer.projection_method), + layer.lscm_seam_angle_deg, int(layer.auto_connect_islands), layer.islands.size()); + if (layer.projection_method != TextureProjectionMethod::LSCM) + continue; + + const indexed_triangle_set patch = + extract_painted_patch(volume->mesh().its, volume->texture_displacement_facet(layer.slot).get_data()); + std::printf(" patch: %zu vertices, %zu triangles\n", patch.vertices.size(), patch.indices.size()); + if (patch.indices.empty()) + continue; + + const auto t0 = std::chrono::steady_clock::now(); + const PatchUnwrap unwrap = compute_patch_unwrap(patch, layer.lscm_seam_angle_deg, 0.f, + layer.lscm_seam_edges); + const auto t1 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_patch_unwrap: %.0f ms\n", + std::chrono::duration(t1 - t0).count()); + std::printf(" unwrap: %d charts, %zu unwrapped triangles\n", unwrap.chart_count, + unwrap.indices.size()); + + // Group the patch's faces by chart so each can be examined on its own. + std::vector> chart_faces(size_t(std::max(unwrap.chart_count, 0))); + for (size_t i = 0; i < unwrap.indices.size(); ++i) { + const int chart = unwrap.vertex_chart[size_t(unwrap.indices[i][0])]; + if (chart >= 0 && size_t(chart) < chart_faces.size()) + chart_faces[size_t(chart)].push_back(unwrap.source_face[i]); + } + + int bad_charts = 0; + for (size_t c = 0; c < chart_faces.size(); ++c) { + int loops = 0, euler = 0; + chart_topology(patch, chart_faces[c], loops, euler); + + // Flipped triangles: the unwrap folded over itself, which is what a planar fallback + // does to a chart that is not flat. Measured on the unwrap's own triangles. + int pos = 0, neg = 0; + for (size_t i = 0; i < unwrap.indices.size(); ++i) { + const stl_triangle_vertex_indices &t = unwrap.indices[i]; + if (unwrap.vertex_chart[size_t(t[0])] != int(c)) + continue; + const float a = signed_area_2d(unwrap.uvs[size_t(t[0])], unwrap.uvs[size_t(t[1])], + unwrap.uvs[size_t(t[2])]); + if (a > 0.f) ++pos; else if (a < 0.f) ++neg; + } + const int flipped = std::min(pos, neg); + const bool disk = loops == 1 && euler == 1; + if (!disk || flipped > 0) { + ++bad_charts; + std::printf(" chart %2zu: %4zu faces loops=%d euler=%d%s flipped=%d/%d%s\n", c, + chart_faces[c].size(), loops, euler, disk ? "" : " NOT A DISK", flipped, + pos + neg, flipped ? " FOLDED" : ""); + } + } + std::printf(" charts with a defect: %d / %d\n", bad_charts, unwrap.chart_count); + + // What the eye actually sees. Every patch edge shared by two charts should carry the same + // UV on both sides once the islands are laid out as a connected net; where it does not, + // the texture jumps across that seam. Measured through compute_lscm_uvs(), i.e. the exact + // coordinates the bake and the checker overlay sample. + { + const auto n0 = std::chrono::steady_clock::now(); + const std::vector net = compute_connected_net(unwrap); + const auto n1 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_connected_net: %.0f ms (%zu islands)\n", + std::chrono::duration(n1 - n0).count(), net.size()); + } + const auto t2 = std::chrono::steady_clock::now(); + const std::vector uv = compute_lscm_uvs(patch, layer); + const auto t3 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_lscm_uvs: %.0f ms (called on every preview, overlay and bake)\n", + std::chrono::duration(t3 - t2).count()); + if (uv.size() != patch.vertices.size()) { + std::printf(" compute_lscm_uvs returned %zu uvs for %zu vertices\n", uv.size(), + patch.vertices.size()); + continue; + } + // Per-corner UVs carry each chart's own placement, so an edge shared by two charts shows + // the jump directly: the same mesh vertex lands at two different UVs. That is exactly what + // the eye reads as the texture breaking. + const auto t4 = std::chrono::steady_clock::now(); + const std::vector corner = compute_lscm_corner_uvs(patch, layer); + const auto t5 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_lscm_corner_uvs: %.0f ms\n", + std::chrono::duration(t5 - t4).count()); + // Keyed by edge, holding the UV each incident face gives to the edge's *lower-numbered* + // endpoint. Comparing that same vertex on both sides is the point: indexing by corner + // position instead compares opposite ends of the edge, because the two faces wind it in + // opposite directions. + std::unordered_map> edge_seen; + if (corner.size() == patch.indices.size() * 3) + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = patch.indices[f]; + for (int k = 0; k < 3; ++k) { + const int a = t[k], b = t[(k + 1) % 3]; + const int probe = std::min(a, b); + const int local = (a == probe) ? k : (k + 1) % 3; + edge_seen[edge_key(a, b)].push_back(corner[f * 3 + size_t(local)]); + } + } + // Which chart each patch face belongs to, so a broken edge can be attributed to a pair. + std::vector chart_of_face(patch.indices.size(), -1); + for (size_t i = 0; i < unwrap.indices.size(); ++i) + chart_of_face[size_t(unwrap.source_face[i])] = unwrap.vertex_chart[size_t(unwrap.indices[i][0])]; + + std::unordered_map> edge_faces; + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = patch.indices[f]; + for (int k = 0; k < 3; ++k) + edge_faces[edge_key(t[k], t[(k + 1) % 3])].push_back(int(f)); + } + + int adjacent = 0, broken = 0, broken_same_chart = 0; + float worst = 0.f; + std::map, std::pair> by_pair; + for (const auto &[key, seen] : edge_seen) { + if (seen.size() != 2) + continue; + ++adjacent; + const float d = (seen[0] - seen[1]).norm(); + if (d <= 1e-4f) + continue; + ++broken; + worst = std::max(worst, d); + const auto &faces_here = edge_faces[key]; + int c1 = -1, c2 = -1; + if (faces_here.size() == 2) { + c1 = chart_of_face[size_t(faces_here[0])]; + c2 = chart_of_face[size_t(faces_here[1])]; + } + if (c1 == c2) + ++broken_same_chart; + auto &slot = by_pair[{ std::min(c1, c2), std::max(c1, c2) }]; + ++slot.first; + slot.second = std::max(slot.second, d); + } + std::printf(" broken edges inside a single chart: %d\n", broken_same_chart); + std::printf(" broken by chart pair:"); + for (const auto &[pk, v] : by_pair) + std::printf(" (%d,%d)x%d/%.1f", pk.first, pk.second, v.first, v.second); + std::printf("\n"); + // Total length of the seams left broken, in mm: how much visibly torn edge the layout has, + // which is what the eye adds up. A count alone hides whether the breaks are hairlines or + // whole sides of an island. + float seam_mm = 0.f; + for (const auto &[key, seen] : edge_seen) { + if (seen.size() != 2 || (seen[0] - seen[1]).norm() <= 1e-4f) + continue; + seam_mm += (patch.vertices[size_t(key >> 32)] - patch.vertices[size_t(uint32_t(key))]).norm(); + } + std::printf(" interior edges: %d, discontinuous: %d, total torn seam: %.2f mm (worst jump %.3f)\n", + adjacent, broken, seam_mm, worst); + std::printf(" stored islands %zu vs charts %d -> %s\n", layer.islands.size(), + unwrap.chart_count, + layer.islands.size() == size_t(unwrap.chart_count) ? "stored placements used" + : "net rebuilt"); + } + } + return 0; +} diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 862c6c9044..a22cc31abc 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1236,52 +1236,81 @@ bool triangles_overlap(const Tri2 &a, const Tri2 &b, float eps) struct NetGrid { static constexpr int BIG_SPAN = 16; - float cell; - float eps; - std::unordered_map> cells; - std::vector big; + // Each stored triangle keeps its own bounding box. Overlap testing is dominated by rejects - a cell + // holds every triangle whose box touches it, and a candidate meets only a couple of them for real - + // so paying six floats per entry to answer most of those rejects with four comparisons, instead of a + // full triangle intersection, is what makes the net affordable. Measured on a 42k-triangle patch the + // grid ran ~19 million candidate pairs per net, nearly all of them misses, and rejecting them this + // way took the net from ~175 ms to ~53 ms. + // + // The box rides inside the entry rather than in a parallel array: splitting them to scan boxes back + // to back was tried and came out slower, because each bucket then grows two vectors instead of one. + struct Entry + { + Tri2 tri; + Vec2f lo, hi; + }; + float cell; + float eps; + std::unordered_map> cells; + std::vector big; static uint64_t key(int x, int y) { return (uint64_t(uint32_t(x)) << 32) | uint32_t(y); } - bool range(const Tri2 &t, int &x0, int &y0, int &x1, int &y1) const + static Entry entry(const Tri2 &t) + { + return Entry{ t, t[0].cwiseMin(t[1]).cwiseMin(t[2]), t[0].cwiseMax(t[1]).cwiseMax(t[2]) }; + } + bool range(const Vec2f &lo, const Vec2f &hi, int &x0, int &y0, int &x1, int &y1) const { - const Vec2f lo = t[0].cwiseMin(t[1]).cwiseMin(t[2]), hi = t[0].cwiseMax(t[1]).cwiseMax(t[2]); x0 = int(std::floor(lo.x() / cell)); y0 = int(std::floor(lo.y() / cell)); x1 = int(std::floor(hi.x() / cell)); y1 = int(std::floor(hi.y() / cell)); return x1 - x0 <= BIG_SPAN && y1 - y0 <= BIG_SPAN; } + // Boxes grown by eps on both sides, to match the tolerance triangles_overlap() itself works to: a + // reject here must never discard a pair that test would have called touching. + bool boxes_apart(const Entry &a, const Entry &b) const + { + return a.hi.x() + eps < b.lo.x() || b.hi.x() + eps < a.lo.x() || a.hi.y() + eps < b.lo.y() || + b.hi.y() + eps < a.lo.y(); + } + bool hits(const Entry &q, const std::vector &bucket) const + { + for (const Entry &b : bucket) + if (!boxes_apart(q, b) && triangles_overlap(q.tri, b.tri, eps)) + return true; + return false; + } bool overlaps(const Tri2 &t) const { - for (const Tri2 &b : big) - if (triangles_overlap(t, b, eps)) - return true; + const Entry q = entry(t); + if (hits(q, big)) + return true; int x0, y0, x1, y1; - if (!range(t, x0, y0, x1, y1)) { - for (const auto &[k, tris] : cells) - for (const Tri2 &b : tris) - if (triangles_overlap(t, b, eps)) - return true; + if (!range(q.lo, q.hi, x0, y0, x1, y1)) { + for (const auto &[k, bucket] : cells) + if (hits(q, bucket)) + return true; return false; } for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) - if (const auto it = cells.find(key(x, y)); it != cells.end()) - for (const Tri2 &b : it->second) - if (triangles_overlap(t, b, eps)) - return true; + if (const auto it = cells.find(key(x, y)); it != cells.end() && hits(q, it->second)) + return true; return false; } void insert(const Tri2 &t) { - int x0, y0, x1, y1; - if (!range(t, x0, y0, x1, y1)) { - big.push_back(t); + const Entry e = entry(t); + int x0, y0, x1, y1; + if (!range(e.lo, e.hi, x0, y0, x1, y1)) { + big.push_back(e); return; } for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) - cells[key(x, y)].push_back(t); + cells[key(x, y)].push_back(e); } }; } // namespace @@ -1293,11 +1322,20 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) if (n <= 1) return islands; - // Chart adjacency, with one representative shared edge per adjacent pair. + // Chart adjacency, with one representative shared edge per adjacent pair: the fold line the pair is + // unfolded about. + // + // Which edge that is matters, because two charts can touch along more than one run. A chart cut open + // to flatten it - a ring opened by segment_into_charts(), say - touches its other half along *both* + // sides of the cut. Folding is rigid, so only the run the fold line belongs to comes out matching; + // every other run is left mismatched, and a mismatched run is exactly where the texture visibly + // jumps. Taking whichever edge the map happened to yield first therefore left the long side broken + // about as often as the short one. The fold line is picked from the longest run instead, so what is + // left discontinuous is the shortest boundary the pair has. const auto edges = build_shared_edges(unwrap); struct PairEdge { ChartEdge a, b; }; - std::map, PairEdge> pair_edge; - std::vector> adj(static_cast(n)); + struct SharedEdge { PairEdge fold; int base_lo = -1, base_hi = -1; float length = 0.f; }; + std::map, std::vector> pair_shared; for (const auto &[base_edge, list] : edges) { for (size_t i = 0; i < list.size(); ++i) for (size_t j = i + 1; j < list.size(); ++j) { @@ -1305,14 +1343,50 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) if (c1 == c2 || c1 < 0 || c2 < 0 || c1 >= n || c2 >= n) continue; const std::pair pk{ std::min(c1, c2), std::max(c1, c2) }; - if (pair_edge.count(pk)) - continue; // keep the first shared edge as the fold line for this pair - pair_edge[pk] = (c1 < c2) ? PairEdge{ list[i], list[j] } : PairEdge{ list[j], list[i] }; - adj[size_t(pk.first)].push_back(pk.second); - adj[size_t(pk.second)].push_back(pk.first); + SharedEdge se; + se.fold = (c1 < c2) ? PairEdge{ list[i], list[j] } : PairEdge{ list[j], list[i] }; + se.base_lo = base_edge.first; + se.base_hi = base_edge.second; + // The unwrap is scaled to true surface area, so a uv distance is a length in mm. + se.length = (unwrap.uvs[size_t(se.fold.a.uv_lo)] - unwrap.uvs[size_t(se.fold.a.uv_hi)]).norm(); + pair_shared[pk].push_back(se); } } + std::map, PairEdge> pair_edge; + std::map, float> pair_weight; // length of the run each pair folds across + std::vector> adj(static_cast(n)); + for (const auto &[pk, shared] : pair_shared) { + // Group the pair's shared edges into runs - edges joined end to end through a base vertex - and + // total each run's length. + std::unordered_map local; + for (const SharedEdge &se : shared) + for (const int v : { se.base_lo, se.base_hi }) + local.emplace(v, int(local.size())); + UnionFind runs(local.size()); + for (const SharedEdge &se : shared) + runs.unite(local[se.base_lo], local[se.base_hi]); + + std::unordered_map run_length; + std::unordered_map run_first; + for (size_t i = 0; i < shared.size(); ++i) { + const int root = runs.find(local[shared[i].base_lo]); + run_length[root] += shared[i].length; + run_first.emplace(root, i); + } + int best_root = -1; + float best_len = -1.f; + for (const auto &[root, len] : run_length) + if (len > best_len) { best_len = len; best_root = root; } + if (best_root < 0) + continue; + + pair_edge[pk] = shared[run_first[best_root]].fold; + pair_weight[pk] = best_len; + adj[size_t(pk.first)].push_back(pk.second); + adj[size_t(pk.second)].push_back(pk.first); + } + // Per chart: its vertices, its triangles and its flattened area. std::vector> chart_verts(static_cast(n)), chart_tris(static_cast(n)); std::vector chart_area(static_cast(n), 0.f); @@ -1366,15 +1440,29 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) for (const int t : chart_tris[size_t(root)]) grid.insert(placed(m, t)); } - std::queue q; - q.push(root); + // Grown strongest-adjacency-first (Prim, not breadth-first): a chart is folded onto whichever + // neighbour it shares the longest boundary with, among everything reachable so far. Order matters + // because only the fold a chart is actually reached by comes out matching - every other boundary + // it has is left to chance. Taking neighbours in breadth-first order, biggest-area first, let a + // far-off branch claim a chart across a short boundary before its true neighbour was reached, and + // the long boundary they shared then stayed broken. That is the visible seam next to a hole: a + // ring is cut into two halves that share a long boundary, and whichever half was reached first + // took the other one along some unrelated edge. + using Candidate = std::pair>; // weight, (from, to) + std::priority_queue q; + const auto push_neighbours = [&](int p) { + for (const int c : adj[size_t(p)]) + if (net_of[size_t(c)] < 0 && !chart_tris[size_t(c)].empty()) { + const auto w = pair_weight.find({ std::min(p, c), std::max(p, c) }); + q.push({ w == pair_weight.end() ? 0.f : w->second, { p, c } }); + } + }; + push_neighbours(root); while (!q.empty()) { - const int p = q.front(); + const auto [weight, link] = q.top(); q.pop(); - std::vector neighbours = adj[size_t(p)]; - std::stable_sort(neighbours.begin(), neighbours.end(), - [&chart_area](int a, int b) { return chart_area[size_t(a)] > chart_area[size_t(b)]; }); - for (const int c : neighbours) { + const int p = link.first, c = link.second; + { if (net_of[size_t(c)] >= 0 || chart_tris[size_t(c)].empty()) continue; const auto it = pair_edge.find({ std::min(p, c), std::max(p, c) }); @@ -1405,7 +1493,7 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) for (const Tri2 &t : tris) grid.insert(t); net_of[size_t(c)] = net; - q.push(c); + push_neighbours(c); } } } @@ -5041,4 +5129,5 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s return out; } + } // namespace Slic3r