#include "TextureDisplacement.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "AABBTreeIndirect.hpp" #include "MeshBoolean.hpp" #include "Model.hpp" #include "PNGReadWrite.hpp" #include "TriangleSelector.hpp" #include "TextureBake/TextureBakeDebug.hpp" #include "TextureBake/TextureBakeMesh.hpp" #include "TextureBake/TextureBakePipeline.hpp" namespace Slic3r { bool DecodedHeightTexture::texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &tap) const { if (empty()) return false; auto repeat01 = [](float x) { x = std::fmod(x, 1.f); return x < 0.f ? x + 1.f : x; }; // Standard mirrored-repeat: reflect back and forth every other unit, so tile edges always // line up with themselves instead of jumping from one edge of the image to the other. auto mirrored_repeat01 = [](float x) { x = std::fmod(std::abs(x), 2.f); return x > 1.f ? 2.f - x : x; }; if (!tile_enabled && (uv.x() < 0.f || uv.x() >= 1.f || uv.y() < 0.f || uv.y() >= 1.f)) // Outside the single, non-repeating placement entirely: no texture there, not "smeared // edge pixel" - clamping the *coordinate* to [0, 1] would otherwise keep returning the // border row/column's height forever in every direction, stretching it out to infinity. return false; float u, v; if (!tile_enabled) { u = uv.x(); v = uv.y(); } else if (tile_method == TextureTileMethod::MirroredRepeat) { u = mirrored_repeat01(uv.x()); v = mirrored_repeat01(uv.y()); } else { u = repeat01(uv.x()); v = repeat01(uv.y()); } const float fx = u * float(width); const float fy = v * float(height); const int x0 = std::clamp(int(std::floor(fx)), 0, width - 1); const int y0 = std::clamp(int(std::floor(fy)), 0, height - 1); // Neighbour for bilinear filtering: wrap for tiling methods, clamp at the edge otherwise (a // repeating neighbour would incorrectly blend against the opposite edge of the image). tap.x0 = x0; tap.y0 = y0; tap.x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1); tap.y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1); tap.tx = fx - std::floor(fx); tap.ty = fy - std::floor(fy); return true; } float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const { TexelTap tap; if (!texel_tap(uv, tile_enabled, tile_method, tap)) return 0.f; const int x0 = tap.x0, y0 = tap.y0, x1 = tap.x1, y1 = tap.y1; const float tx = tap.tx, ty = tap.ty; auto at = [this](int x, int y) { return float(pixels[size_t(y) * size_t(width) + size_t(x)]) / 255.f; }; const float top = at(x0, y0) * (1.f - tx) + at(x1, y0) * tx; const float bottom = at(x0, y1) * (1.f - tx) + at(x1, y1) * tx; return top * (1.f - ty) + bottom * ty; } Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const { TexelTap tap; if (!has_color() || !texel_tap(uv, tile_enabled, tile_method, tap)) return Vec3f::Zero(); auto at = [this](int x, int y) { const size_t i = (size_t(y) * size_t(width) + size_t(x)) * 3; return Vec3f(float(rgb[i]) / 255.f, float(rgb[i + 1]) / 255.f, float(rgb[i + 2]) / 255.f); }; const Vec3f top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx; const Vec3f bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx; return top * (1.f - tap.ty) + bottom * tap.ty; } namespace { // Decoding a PNG (zlib inflate + defilter) is real work, and image_data never changes in place // once assigned to a layer (a new texture always gets a brand new image_data), so the decoded // result can be cached for the lifetime of that specific image_data allocation. This matters // because the GUI's live preview calls decode_height_texture() again on every rebuild (every // paint stroke / parameter tweak), which would otherwise re-decode the same unchanged bytes over // and over. Keyed by a weak_ptr (not just the raw pointer) so a freed image_data's address being // reused by an unrelated later allocation can never alias a stale cache entry: a weak_ptr to a // destroyed object always fails to lock, forcing a correct re-decode instead of a false hit. struct DecodedTextureCache { std::mutex mutex; std::unordered_map>, DecodedHeightTexture>> entries; }; DecodedTextureCache g_decoded_texture_cache; // The smoothed copy is cached too, one per image: the thumbnail, the shaded-preview height texture, the // colour texture, the projector texture, the UV editor's background and the preview job all ask for // the same (image, smoothing) pair in the same frame while the Smoothing slider moves, and each of them // blurring its own copy is what froze the UI. Keyed like the raw cache; a different smoothing value // simply replaces the entry. struct SmoothedTextureCache { struct Entry { std::weak_ptr> source; float smoothing = 0.f; DecodedHeightTexture texture; }; std::mutex mutex; std::unordered_map entries; }; SmoothedTextureCache g_smoothed_texture_cache; } // namespace namespace { // A few passes of a separable box blur approximate a Gaussian, cheaply. `radius` is in whole texels; // 0 is a no-op. Wraps at the edges so a tiling height map stays seamless after smoothing. Operates on // the grayscale byte buffer in place. // // Each pass is a sliding window - one add and one subtract per texel - so the cost is the image size, // not the image size times the radius. The Smoothing slider drives this on every frame it moves, for // every consumer of the layer, and the radius goes up to 48 texels: the per-window loop this replaces // stalled the UI for seconds on a large map. Rows (and column blocks) run in parallel. The rounding is // the old code's exactly, so a blur gives the same bytes as before. void smooth_height_pixels_box(std::vector &pixels, int width, int height, int radius) { if (radius <= 0 || width <= 0 || height <= 0 || pixels.size() != size_t(width) * size_t(height)) return; const int window = 2 * radius + 1; const float inv = 1.f / float(window); const auto wrap = [](int i, int n) { return (i % n + n) % n; }; std::vector tmp(pixels.size()); for (int pass = 0; pass < 2; ++pass) { // two passes -> smoother than a single box // Horizontal: a running sum per row. tbb::parallel_for(tbb::blocked_range(0, height), [&](const tbb::blocked_range &r) { for (int y = r.begin(); y < r.end(); ++y) { const uint8_t *src = pixels.data() + size_t(y) * size_t(width); uint8_t *dst = tmp.data() + size_t(y) * size_t(width); uint32_t sum = 0; for (int k = -radius; k <= radius; ++k) sum += src[wrap(k, width)]; for (int x = 0; x < width; ++x) { dst[x] = uint8_t(std::lround(float(sum) * inv)); sum += src[wrap(x + radius + 1, width)]; sum -= src[wrap(x - radius, width)]; } } }); // Vertical: one running sum per column, advanced row by row so the reads stay row-major. tbb::parallel_for(tbb::blocked_range(0, width, 256), [&](const tbb::blocked_range &r) { std::vector sum(size_t(r.size()), 0); for (int k = -radius; k <= radius; ++k) { const uint8_t *row = tmp.data() + size_t(wrap(k, height)) * size_t(width); for (int x = r.begin(); x < r.end(); ++x) sum[size_t(x - r.begin())] += row[x]; } for (int y = 0; y < height; ++y) { uint8_t *dst = pixels.data() + size_t(y) * size_t(width); const uint8_t *add = tmp.data() + size_t(wrap(y + radius + 1, height)) * size_t(width); const uint8_t *sub = tmp.data() + size_t(wrap(y - radius, height)) * size_t(width); for (int x = r.begin(); x < r.end(); ++x) { uint32_t &sx = sum[size_t(x - r.begin())]; dst[x] = uint8_t(std::lround(float(sx) * inv)); sx += add[x]; sx -= sub[x]; } } }); } } // The same blur with a *continuous* radius, which is what the Smoothing slider drives. // // A box blur can only work in whole texels, so mapping the slider straight onto a rounded radius // made it move in visible jumps - and its very first step off zero was a full one-texel blur rather // than a hint of one, which is what made the control feel like it switched on rather than ramped up. // Blur at the next whole texel up and cross-fade the raw image back in by the fraction left over: // below one texel that fade *is* the sub-texel kernel, and above it it turns each integer step into // a continuous ramp. void smooth_height_pixels(std::vector &pixels, int width, int height, float radius) { if (radius <= 0.f || width <= 0 || height <= 0 || pixels.size() != size_t(width) * size_t(height)) return; const int whole = std::max(1, int(std::ceil(radius))); const float mix = std::clamp(radius / float(whole), 0.f, 1.f); const std::vector raw = (mix < 0.999f) ? pixels : std::vector{}; smooth_height_pixels_box(pixels, width, height, whole); if (!raw.empty()) for (size_t i = 0; i < pixels.size(); ++i) pixels[i] = uint8_t(std::lround(float(raw[i]) + (float(pixels[i]) - float(raw[i])) * mix)); } } // namespace DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) { DecodedHeightTexture result; if (layer.empty()) return result; // The raw (unsmoothed) decode is what gets cached, keyed by the image_data allocation - decoding // a PNG is the expensive part and never changes for a given image. Smoothing is applied afterwards // to a throwaway copy, so moving the smoothing slider never invalidates the decode cache. const void *key = layer.image_data.get(); bool have_raw = false; { std::lock_guard lock(g_decoded_texture_cache.mutex); auto it = g_decoded_texture_cache.entries.find(key); if (it != g_decoded_texture_cache.entries.end() && it->second.first.lock() == layer.image_data) { result = it->second.second; have_raw = true; } } if (!have_raw) { const png::ReadBuf rbuf{ layer.image_data->data(), layer.image_data->size() }; if (!png::is_png(rbuf)) // PNG only. The GUI converts any other imported format (jpg, bmp, ...) on import, so this // code needs no dependency on wxWidgets/libjpeg to read arbitrary user images. return result; png::ImageGreyscale img; if (png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0) { // The shipped library, and anything imported before colour was kept. result.width = int(img.cols); result.height = int(img.rows); result.pixels = std::move(img.buf); } else { // A colour source: keep the colour, and take the height from its luminance. The // coefficients are wxImage::ConvertToGreyscale()'s, which is what the importer used to // apply on the way in - so a texture that used to be flattened to grey at import time // displaces identically now that its colour is preserved. // The loop below steps bytes_per_pixel per texel, which only holds at 8 bits per channel: // decode_colored_png() does not narrow a 16-bit image, so that would read as noise. png::ImageColorscale col; if (!png::decode_colored_png(rbuf, col) || col.cols == 0 || col.rows == 0 || col.bytes_per_pixel < 3 || col.buf.size() != col.cols * col.rows * size_t(col.bytes_per_pixel)) return result; const int w = int(col.cols); const int h = int(col.rows); const size_t bpp = size_t(col.bytes_per_pixel); result.width = w; result.height = h; result.pixels.resize(size_t(w) * size_t(h)); result.rgb.resize(size_t(w) * size_t(h) * 3); // decode_colored_png() hands its buffer back bottom-up - it is shared with the CLI's // plate-thumbnail loader, which expects that - while this type, and decode_png()'s // grayscale path above, are top-to-bottom. Reverse the rows on the way in so a colour // height map displaces the same way up as a grayscale one. for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { const uint8_t *src = col.buf.data() + (size_t(h - 1 - y) * size_t(w) + size_t(x)) * bpp; const size_t dst = size_t(y) * size_t(w) + size_t(x); const uint8_t r = src[0], g = src[1], b = src[2]; result.rgb[dst * 3] = r; result.rgb[dst * 3 + 1] = g; result.rgb[dst * 3 + 2] = b; result.pixels[dst] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); } } std::lock_guard lock(g_decoded_texture_cache.mutex); // Opportunistically drop entries for image_data that no longer exists anywhere, so the // cache doesn't grow without bound across many add/remove-texture cycles in a long session. auto &entries = g_decoded_texture_cache.entries; for (auto it = entries.begin(); it != entries.end();) it = it->second.first.expired() ? entries.erase(it) : std::next(it); entries[key] = { std::weak_ptr>(layer.image_data), result }; } // Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of // the image whatever resolution it came in at, and stays continuous in the slider - see // smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is // O(width * height * radius) per pass, so a large map with the slider at the top would otherwise // stall every preview rebuild. if (layer.smoothing > 0.f) { { std::lock_guard lock(g_smoothed_texture_cache.mutex); auto it = g_smoothed_texture_cache.entries.find(key); if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing && it->second.source.lock() == layer.image_data) return it->second.texture; } const float span = 0.05f * float(std::min(result.width, result.height)); const float radius = std::clamp(layer.smoothing, 0.f, 1.f) * std::min(span, 48.f); smooth_height_pixels(result.pixels, result.width, result.height, radius); // Colour gets the same blur, per channel. It is the same knob for the same reason: detail in // the image finer than the mesh can carry is noise either way, and low-passing it here is the // cheapest place to remove it - one blur of the texture, rather than a fight per triangle. if (result.has_color()) { const size_t n = size_t(result.width) * size_t(result.height); std::vector channel(n); for (int c = 0; c < 3; ++c) { for (size_t i = 0; i < n; ++i) channel[i] = result.rgb[i * 3 + size_t(c)]; smooth_height_pixels(channel, result.width, result.height, radius); for (size_t i = 0; i < n; ++i) result.rgb[i * 3 + size_t(c)] = channel[i]; } } std::lock_guard lock(g_smoothed_texture_cache.mutex); auto &entries = g_smoothed_texture_cache.entries; for (auto it = entries.begin(); it != entries.end();) it = it->second.source.expired() ? entries.erase(it) : std::next(it); entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, result }; } return result; } namespace { struct TextureDetailCache { std::mutex mutex; std::unordered_map>, TextureDetail>> entries; }; TextureDetailCache g_texture_detail_cache; } // namespace TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) { TextureDetail out; if (layer.empty()) return out; const void *key = layer.image_data.get(); { std::lock_guard lock(g_texture_detail_cache.mutex); auto it = g_texture_detail_cache.entries.find(key); if (it != g_texture_detail_cache.entries.end() && it->second.first.lock() == layer.image_data) return it->second.second; } // On the image as imported: the Smoothing slider must not move the recommendation around. TextureDisplacementLayer raw = layer; raw.smoothing = 0.f; const DecodedHeightTexture tex = decode_height_texture(raw); const int w = tex.width, h = tex.height; if (w >= 3 && h >= 3) { double sum = 0.0; size_t sharp = 0, n = 0; for (int y = 1; y < h - 1; ++y) { const uint8_t *row = tex.pixels.data() + size_t(y) * size_t(w); for (int x = 1; x < w - 1; ++x) { const float dx = 0.5f * (float(row[x + 1]) - float(row[x - 1])); const float dy = 0.5f * (float(row[x + w]) - float(row[x - w])); const float mag = std::sqrt(dx * dx + dy * dy); sum += mag; sharp += mag > 30.f; ++n; } } out.mean_gradient = float(sum / double(n)); out.sharp_fraction = float(sharp) / float(n); if (out.sharp_fraction > 0.15f || out.mean_gradient > 50.f) out.pixels_per_edge = 1.f; else if (out.sharp_fraction > 0.05f || out.mean_gradient > 20.f) out.pixels_per_edge = 1.5f; else if (out.mean_gradient > 8.f) out.pixels_per_edge = 2.5f; else out.pixels_per_edge = 4.f; // Colour spread: a coarse histogram (8 levels per channel, 64 levels for a grey image) and // the share of the eight fullest bins. Tiles, logos and camouflage put nearly everything in a // handful of bins even with some texture noise; a photograph spreads across hundreds. std::vector bins(size_t(8 * 8 * 8), 0); const size_t npx = size_t(w) * size_t(h); if (tex.has_color()) for (size_t i = 0; i < npx; ++i) ++bins[size_t(tex.rgb[i * 3] >> 5) * 64 + size_t(tex.rgb[i * 3 + 1] >> 5) * 8 + size_t(tex.rgb[i * 3 + 2] >> 5)]; else for (size_t i = 0; i < npx; ++i) ++bins[size_t(tex.pixels[i] >> 2) * 8]; // 64 grey levels, spread over distinct bins std::partial_sort(bins.begin(), bins.begin() + 8, bins.end(), std::greater()); uint64_t top = 0; for (int i = 0; i < 8; ++i) top += bins[size_t(i)]; out.flat_share = float(double(top) / double(npx)); out.flat_colors = out.flat_share >= 0.85f; } std::lock_guard lock(g_texture_detail_cache.mutex); auto &entries = g_texture_detail_cache.entries; for (auto it = entries.begin(); it != entries.end();) it = it->second.first.expired() ? entries.erase(it) : std::next(it); entries[key] = { std::weak_ptr>(layer.image_data), out }; return out; } V2Resolution recommend_v2_resolution(const indexed_triangle_set &mesh, const std::vector &layers, const Transform3d &volume_to_world) { // The defaults on model load: edge = diagonal / 250 in [0.05, 5] mm, budget 750 k. A texture-driven // variant (resolution from the texture's own detail) was measured to give better walls on step // textures at 2-10x the bake time and up to 2 M output triangles, and was not worth that; these // defaults stayed. The texel size and sharpness are still reported for the panel. constexpr double EDGE_MIN = 0.05, EDGE_MAX = 5.0, DIAG_DIVISOR = 250.0; constexpr int BUDGET_K = 750; V2Resolution out; if (mesh.vertices.empty()) return out; for (const TextureDisplacementLayer &layer : layers) { if (layer.empty() || layer.tiling_scale <= 0.f) continue; const DecodedHeightTexture &tex = decode_height_texture(layer); if (tex.width <= 0) continue; const float texel = layer.tiling_scale / float(tex.width); if (out.texel_mm <= 0.f || texel < out.texel_mm) { out.texel_mm = texel; out.pixels_per_edge = analyze_texture_detail(layer).pixels_per_edge; } } Vec3d bmin = Vec3d::Constant(std::numeric_limits::max()), bmax = -bmin; for (const Vec3f &v : mesh.vertices) { const Vec3d w = volume_to_world * v.cast(); bmin = bmin.cwiseMin(w); bmax = bmax.cwiseMax(w); } const double diag = (bmax - bmin).norm(); double edge = std::clamp(diag / DIAG_DIVISOR, EDGE_MIN, EDGE_MAX); edge = std::max(EDGE_MIN, std::ceil(edge * 100.0) / 100.0); out.edge_mm = float(edge); out.budget_k = BUDGET_K; out.budget_bound = false; return out; } Vec2f project_planar(const Vec3f &position, const Vec3f &normal) { // Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called // with each vertex's *own* normal (TextureProjectionMethod::Triplanar), this is a standard // tri-planar/cube projection; a patch spanning several differently-oriented faces gets each // face projected along its own best-fit axis instead of all faces sharing one axis picked // from a single averaged normal (which looks correct on one face but visibly distorts on any // other face in the same patch - exactly the bug an earlier version of this feature had). const Vec3f n = normal.cwiseAbs(); if (n.x() >= n.y() && n.x() >= n.z()) return Vec2f(position.y(), position.z()); if (n.y() >= n.x() && n.y() >= n.z()) return Vec2f(position.x(), position.z()); return Vec2f(position.x(), position.y()); } namespace { // Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at // this point's own radius, v is the signed distance along the axis - a reasonable approximation // for roughly cylindrical selections, not an exact fit for arbitrary geometry. Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis) { Vec3f up = patch_axis; up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ(); const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f right = Vec3f(up.cross(arbitrary).normalized()); const Vec3f fwd = Vec3f(right.cross(up).normalized()); const Vec3f rel = position - patch_center; const float along_axis = rel.dot(up); const float x = rel.dot(right); const float y = rel.dot(fwd); const float radius = std::sqrt(x * x + y * y); const float angle = std::atan2(y, x); return Vec2f(angle * radius, along_axis); } // Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the // center so the result is in roughly the same mm-ish units tiling_scale expects, rather than bare // radians - again an approximation, not an exact geodesic parametrization. Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center) { const Vec3f rel = position - patch_center; const float radius = rel.norm(); if (radius < 1e-8f) return Vec2f::Zero(); const Vec3f dir = rel / radius; const float longitude = std::atan2(dir.y(), dir.x()); const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f)); return Vec2f(longitude, latitude) * radius; } // CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but // `patch` here (from TriangleSelector::get_facets_strict()) carries the *entire* mesh's vertex // array, only its `indices` filtered to the painted triangles. Build a compacted copy referencing // only the vertices `patch.indices` actually uses, plus a map back to the original vertex index so // the resulting per-vertex UVs can be looked up by the caller's own (uncompacted) indexing. indexed_triangle_set compact_patch_with_map(const indexed_triangle_set &patch, std::vector &original_to_compact) { original_to_compact.assign(patch.vertices.size(), -1); indexed_triangle_set compact; compact.vertices.reserve(patch.vertices.size()); compact.indices.reserve(patch.indices.size()); for (const stl_triangle_vertex_indices &tri : patch.indices) { stl_triangle_vertex_indices new_tri; for (int i = 0; i < 3; ++i) { const int vi = tri[i]; if (original_to_compact[vi] < 0) { original_to_compact[vi] = int(compact.vertices.size()); compact.vertices.push_back(patch.vertices[vi]); } new_tri[i] = original_to_compact[vi]; } compact.indices.push_back(new_tri); } return compact; } } // namespace namespace { // Union-find over triangles, used to grow charts. struct UnionFind { std::vector parent; explicit UnionFind(size_t n) : parent(n) { std::iota(parent.begin(), parent.end(), 0); } int find(int x) { while (parent[size_t(x)] != x) { parent[size_t(x)] = parent[size_t(parent[size_t(x)])]; // path halving x = parent[size_t(x)]; } return x; } void unite(int a, int b) { a = find(a); b = find(b); if (a != b) parent[size_t(b)] = a; } }; uint64_t undirected_edge_key(int a, int b) { if (a > b) std::swap(a, b); return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); } Vec3f face_normal(const indexed_triangle_set &mesh, const stl_triangle_vertex_indices &tri) { const Vec3f n = (mesh.vertices[tri[1]] - mesh.vertices[tri[0]]).cross(mesh.vertices[tri[2]] - mesh.vertices[tri[0]]); const float len = n.norm(); return (len > 1e-12f) ? Vec3f(n / len) : Vec3f::UnitZ(); } // Whether a chart can be laid flat as one island: a topological disk (V - E + F = 1 with a single boundary loop, // which LSCM requires) whose faces all point within ~100 degrees of their average - past that even a disk folds // over itself when flattened. Degenerate faces carry no direction and are left out of the normal test. bool chart_is_flattenable(const indexed_triangle_set &mesh, const std::vector &normals, const std::vector &faces) { constexpr float MIN_NORMAL_COS = -0.17f; // cos(100 deg) std::unordered_map edge_use; std::unordered_map local_vertex; edge_use.reserve(faces.size() * 2); local_vertex.reserve(faces.size()); Vec3f normal_sum = Vec3f::Zero(); for (const int f : faces) { const stl_triangle_vertex_indices &tri = mesh.indices[size_t(f)]; for (int i = 0; i < 3; ++i) { ++edge_use[undirected_edge_key(tri[i], tri[(i + 1) % 3])]; local_vertex.emplace(tri[i], int(local_vertex.size())); } normal_sum += normals[size_t(f)]; } if (int(local_vertex.size()) - int(edge_use.size()) + int(faces.size()) != 1) return false; UnionFind loops(local_vertex.size()); int boundary_seed = -1; for (const auto &[key, uses] : edge_use) { if (uses > 2) return false; // non-manifold if (uses == 1) { const int a = local_vertex[int(key >> 32)], b = local_vertex[int(uint32_t(key))]; loops.unite(a, b); boundary_seed = a; } } if (boundary_seed < 0) return false; // closed const int loop = loops.find(boundary_seed); for (const auto &[key, uses] : edge_use) if (uses == 1 && loops.find(local_vertex[int(key >> 32)]) != loop) return false; // a second boundary loop: a ring, or a disk with a hole const float len = normal_sum.norm(); if (len < 1e-6f) return false; const Vec3f mean = normal_sum / len; for (const int f : faces) { const stl_triangle_vertex_indices &tri = mesh.indices[size_t(f)]; const bool degenerate = (mesh.vertices[tri[1]] - mesh.vertices[tri[0]]).cross(mesh.vertices[tri[2]] - mesh.vertices[tri[0]]) .squaredNorm() < 1e-20f; if (!degenerate && normals[size_t(f)].dot(mean) < MIN_NORMAL_COS) return false; } return true; } // Which side of a cut each face of an unflattenable chart goes to (true / false, parallel to `faces`). The cut // separates the faces' normals along their widest spread, so a tube splits lengthwise into two half-tubes and a // closed sphere into two hemispheres. Where the normals barely vary (a flat ring) it splits the face centroids // along their longest axis instead. std::vector split_chart_sides(const indexed_triangle_set &mesh, const std::vector &normals, const std::vector &faces) { const auto principal_axis = [](const std::vector &samples, Vec3f &mean, Vec3f &axis) { mean = Vec3f::Zero(); for (const Vec3f &s : samples) mean += s; mean /= float(samples.size()); Eigen::Matrix3f covariance = Eigen::Matrix3f::Zero(); for (const Vec3f &s : samples) { const Vec3f d = s - mean; covariance += d * d.transpose(); } const Eigen::SelfAdjointEigenSolver solver(covariance); axis = solver.eigenvectors().col(2); // eigenvalues come sorted ascending return solver.eigenvalues()(2) / float(samples.size()); }; std::vector samples(faces.size()); for (size_t i = 0; i < faces.size(); ++i) samples[i] = normals[size_t(faces[i])]; Vec3f mean, axis; if (principal_axis(samples, mean, axis) < 1e-3f) { for (size_t i = 0; i < faces.size(); ++i) { const stl_triangle_vertex_indices &tri = mesh.indices[size_t(faces[i])]; samples[i] = (mesh.vertices[tri[0]] + mesh.vertices[tri[1]] + mesh.vertices[tri[2]]) / 3.f; } principal_axis(samples, mean, axis); } std::vector side(faces.size()); for (size_t i = 0; i < faces.size(); ++i) side[i] = (samples[i] - mean).dot(axis) > 0.f; return side; } // Groups triangles into charts. First two triangles sharing an edge join the same chart only if the angle between // their normals is below `seam_angle_deg` (and the edge is not a marked seam): everything sharper is a seam. The // test uses each face's own normal - averaging in its neighbours, as this once did, cuts low-poly flat faces apart, // because the two triangles of one cube face average in different neighbouring faces. // // Then every chart that cannot be laid flat as one island (see chart_is_flattenable()) is cut in two and each // connected piece checked again: a cylinder's smooth side is one chart by angle but a ring, which no flattening // can open without a cut, and a sphere is closed. std::vector segment_into_charts(const indexed_triangle_set &mesh, const std::vector &normals, float seam_angle_deg, const std::unordered_set &seam_keys, int &chart_count) { constexpr int MAX_SPLIT_DEPTH = 8; const int n_faces = int(mesh.indices.size()); const float cos_threshold = std::cos(std::clamp(seam_angle_deg, 0.f, 180.f) * float(M_PI) / 180.f); // Each shared edge, with the (up to two) faces on it. std::unordered_map> edge_faces; edge_faces.reserve(size_t(n_faces) * 3); for (int f = 0; f < n_faces; ++f) { const stl_triangle_vertex_indices &tri = mesh.indices[size_t(f)]; for (int i = 0; i < 3; ++i) { const uint64_t key = undirected_edge_key(tri[i], tri[(i + 1) % 3]); const auto [it, inserted] = edge_faces.emplace(key, std::make_pair(f, -1)); if (!inserted) it->second.second = f; } } // Faces joined across each edge that does not cut. A face has three edges, so at most three such neighbours. std::vector> adjacent(static_cast(n_faces), { -1, -1, -1 }); const auto link = [&adjacent](int f, int nb) { for (int &slot : adjacent[size_t(f)]) if (slot < 0) { slot = nb; return; } }; for (const auto &[key, fp] : edge_faces) { if (fp.second < 0 || fp.first == fp.second) continue; // a boundary edge of the patch, nothing on the far side to join // A manually/auto marked seam always cuts, whatever the dihedral angle - that is exactly // what lets "mark seam" / "cut island" split a chart that is otherwise flat enough to merge. if (!seam_keys.empty() && seam_keys.count(key)) continue; if (normals[size_t(fp.first)].dot(normals[size_t(fp.second)]) >= cos_threshold) { link(fp.first, fp.second); link(fp.second, fp.first); } } // Connected pieces of `faces` over `adjacent`, staying within each face's current `group`. std::vector group(static_cast(n_faces), 0), visited(static_cast(n_faces), -1); int visit_pass = 0; const auto connected_pieces = [&](const std::vector &faces, std::vector> &out) { const int pass = visit_pass++; std::vector stack; for (const int seed : faces) { if (visited[size_t(seed)] == pass) continue; visited[size_t(seed)] = pass; std::vector piece{ seed }; stack.assign(1, seed); while (!stack.empty()) { const int f = stack.back(); stack.pop_back(); for (const int nb : adjacent[size_t(f)]) if (nb >= 0 && visited[size_t(nb)] != pass && group[size_t(nb)] == group[size_t(f)]) { visited[size_t(nb)] = pass; piece.push_back(nb); stack.push_back(nb); } } out.push_back(std::move(piece)); } }; std::vector all_faces(static_cast(n_faces)); std::iota(all_faces.begin(), all_faces.end(), 0); std::vector> pieces; connected_pieces(all_faces, pieces); std::vector, int>> pending; // a piece, and how many cuts produced it for (std::vector &piece : pieces) pending.emplace_back(std::move(piece), 0); std::vector> charts; int next_group = 1; while (!pending.empty()) { std::vector faces = std::move(pending.back().first); const int depth = pending.back().second; pending.pop_back(); if (faces.size() < 2 || depth >= MAX_SPLIT_DEPTH || chart_is_flattenable(mesh, normals, faces)) { charts.push_back(std::move(faces)); continue; } const std::vector side = split_chart_sides(mesh, normals, faces); std::vector halves[2]; for (size_t i = 0; i < faces.size(); ++i) halves[side[i] ? 1 : 0].push_back(faces[i]); if (halves[0].empty() || halves[1].empty()) { charts.push_back(std::move(faces)); continue; } for (std::vector &half : halves) { for (const int f : half) group[size_t(f)] = next_group; ++next_group; pieces.clear(); connected_pieces(half, pieces); for (std::vector &piece : pieces) pending.emplace_back(std::move(piece), depth + 1); } } // Chart ids in first-encountered-triangle order, which TextureIsland indexing documents. std::vector> first_face(charts.size()); for (size_t c = 0; c < charts.size(); ++c) first_face[c] = { *std::min_element(charts[c].begin(), charts[c].end()), c }; std::sort(first_face.begin(), first_face.end()); std::vector chart_of(static_cast(n_faces), -1); for (size_t id = 0; id < first_face.size(); ++id) for (const int f : charts[first_face[id].second]) chart_of[size_t(f)] = int(id); chart_count = int(charts.size()); return chart_of; } // Projects a chart onto an orthonormal basis of its own average normal. This is *isometric* for a // flat chart - lengths and angles come out exactly right - which is why a flat chart never needs a // solve at all, and why this also serves as the fallback for a chart LSCM cannot handle. std::vector project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) { const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f u = Vec3f(seed.cross(normal).normalized()); const Vec3f v = Vec3f(normal.cross(u).normalized()); std::vector uvs(chart.vertices.size()); for (size_t i = 0; i < chart.vertices.size(); ++i) uvs[i] = Vec2f(chart.vertices[i].dot(u), chart.vertices[i].dot(v)); return uvs; } float area_3d(const indexed_triangle_set &mesh) { float area = 0.f; for (const stl_triangle_vertex_indices &t : mesh.indices) area += 0.5f * (mesh.vertices[t[1]] - mesh.vertices[t[0]]).cross(mesh.vertices[t[2]] - mesh.vertices[t[0]]).norm(); return area; } float area_2d(const std::vector &uvs, const std::vector &indices) { float area = 0.f; for (const stl_triangle_vertex_indices &t : indices) { const Vec2f e0 = uvs[size_t(t[1])] - uvs[size_t(t[0])]; const Vec2f e1 = uvs[size_t(t[2])] - uvs[size_t(t[0])]; area += 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x()); } return area; } // FNV-1a over the patch's geometry plus the seam angle. The unwrap depends on nothing else about a // layer - not depth, tiling, rotation, offset or even which texture is on it - so keying the cache // on just this is what lets every one of those sliders be dragged without paying for a re-solve. uint64_t unwrap_cache_key(const indexed_triangle_set &patch, float seam_angle_deg, float padding_mm, const std::vector> &seam_edges) { uint64_t h = 1469598103934665603ull; const auto mix = [&h](const void *data, size_t bytes) { const unsigned char *p = static_cast(data); for (size_t i = 0; i < bytes; ++i) { h ^= p[i]; h *= 1099511628211ull; } }; mix(patch.vertices.data(), patch.vertices.size() * sizeof(Vec3f)); mix(patch.indices.data(), patch.indices.size() * sizeof(stl_triangle_vertex_indices)); mix(&seam_angle_deg, sizeof(seam_angle_deg)); mix(&padding_mm, sizeof(padding_mm)); mix(seam_edges.data(), seam_edges.size() * sizeof(std::pair)); return h; } struct UnwrapCache { std::mutex mutex; std::unordered_map entries; }; UnwrapCache g_unwrap_cache; const size_t UNWRAP_CACHE_MAX_ENTRIES = 8; } // namespace PatchUnwrap compute_patch_unwrap(const indexed_triangle_set &patch, float seam_angle_deg, float padding_mm, const std::vector> &seam_edges) { PatchUnwrap result; if (patch.indices.empty()) return result; const uint64_t key = unwrap_cache_key(patch, seam_angle_deg, padding_mm, seam_edges); { std::lock_guard lock(g_unwrap_cache.mutex); if (auto it = g_unwrap_cache.entries.find(key); it != g_unwrap_cache.entries.end()) return it->second; } // CGAL wants a mesh with no unreferenced vertices; `patch` carries the whole mesh's vertex array // (see get_facets_strict()), so compact it and keep the map back to the caller's numbering. std::vector patch_to_compact; const indexed_triangle_set compact = compact_patch_with_map(patch, patch_to_compact); std::vector compact_to_patch(compact.vertices.size(), -1); for (size_t vi = 0; vi < patch_to_compact.size(); ++vi) if (patch_to_compact[vi] >= 0) compact_to_patch[size_t(patch_to_compact[vi])] = int(vi); std::vector normals(compact.indices.size()); for (size_t f = 0; f < compact.indices.size(); ++f) normals[f] = face_normal(compact, compact.indices[f]); // Seam edges arrive in patch (== mesh) vertex space; translate to the compact numbering the // segmentation runs in. An edge whose endpoints didn't both survive compaction is simply ignored. std::unordered_set seam_keys; seam_keys.reserve(seam_edges.size() * 2); for (const auto &[a, b] : seam_edges) { if (a < 0 || b < 0 || size_t(a) >= patch_to_compact.size() || size_t(b) >= patch_to_compact.size()) continue; const int ca = patch_to_compact[size_t(a)]; const int cb = patch_to_compact[size_t(b)]; if (ca >= 0 && cb >= 0) seam_keys.insert(undirected_edge_key(ca, cb)); } // Once the user has marked seams by hand, those seams define the islands: the automatic // sharp-angle cutting must not keep splitting faces the user left un-seamed, or islands the user // meant to be one piece never merge (the "seam angle overrides it" the user hit). So when any // manual seam exists, the angle threshold is dropped to 180 degrees - nothing is cut except the // marked seams. With no manual seams, the seam angle behaves exactly as before. const float effective_seam_angle = seam_keys.empty() ? seam_angle_deg : 180.f; int chart_count = 0; const std::vector chart_of = segment_into_charts(compact, normals, effective_seam_angle, seam_keys, chart_count); struct FlatChart { std::vector uvs; std::vector to_patch; // chart vertex -> patch vertex std::vector indices; // chart-local // Parallel to `indices`: the patch triangle each one came from. compact_patch_with_map() keeps // the patch's triangle count *and* order, so a compact face index is already a patch face index. std::vector faces; Vec2f min = Vec2f::Zero(); Vec2f size = Vec2f::Zero(); }; // static_cast, not size_t(...): the latter is a most-vexing-parse and declares a function. std::vector charts(static_cast(chart_count)); for (int c = 0; c < chart_count; ++c) { FlatChart &chart = charts[size_t(c)]; // Build the chart's own sub-mesh. Each chart gets its *own* compact->local vertex map, which // is exactly what duplicates a seam vertex: it appears once in each chart that touches it, // free to hold a different UV in each. indexed_triangle_set chart_mesh; std::vector compact_to_local(compact.vertices.size(), -1); Vec3f normal_sum = Vec3f::Zero(); for (size_t f = 0; f < compact.indices.size(); ++f) { if (chart_of[f] != c) continue; const stl_triangle_vertex_indices &tri = compact.indices[f]; // Area-weighted, so a chart's average normal isn't dragged around by slivers. normal_sum += (compact.vertices[tri[1]] - compact.vertices[tri[0]]).cross(compact.vertices[tri[2]] - compact.vertices[tri[0]]); stl_triangle_vertex_indices local_tri; for (int i = 0; i < 3; ++i) { const int cv = tri[i]; if (compact_to_local[size_t(cv)] < 0) { compact_to_local[size_t(cv)] = int(chart_mesh.vertices.size()); chart_mesh.vertices.push_back(compact.vertices[size_t(cv)]); chart.to_patch.push_back(compact_to_patch[size_t(cv)]); } local_tri[i] = compact_to_local[size_t(cv)]; } chart_mesh.indices.push_back(local_tri); chart.faces.push_back(int(f)); } if (chart_mesh.indices.empty()) continue; const Vec3f chart_normal = (normal_sum.norm() > 1e-12f) ? Vec3f(normal_sum.normalized()) : Vec3f::UnitZ(); // Is the chart flat? Charts are grown by a *pairwise* angle threshold, so a chart can still // curve gradually across many triangles - being merged is not the same as being planar. But // when it is planar (a cube face, and after seam-cutting that is the common case), the // tangent-plane projection is already the exact answer, and skipping the solve is the single // biggest speed-up here. bool planar = true; for (size_t f = 0; f < compact.indices.size() && planar; ++f) if (chart_of[f] == c) planar = normals[f].dot(chart_normal) >= 0.9998f; // ~1 degree // Measured before chart_mesh.indices is moved out from under it, below - area_3d() iterates // those indices, so taking it afterwards silently measures an empty mesh and returns 0. const float mesh_area_3d = area_3d(chart_mesh); std::optional> uvs; if (!planar) uvs = MeshBoolean::cgal::parameterize_lscm(chart_mesh); // Flat chart, or one LSCM refused (not a topological disk - closed, or holed). chart.uvs = uvs ? std::move(*uvs) : project_to_tangent_plane(chart_mesh, chart_normal); chart.indices = std::move(chart_mesh.indices); // LSCM's output is only defined up to a similarity, so charts come back at arbitrary and // mutually inconsistent scales. Rescale each to its true surface area, so `Tile size (mm)` // means the same thing on every chart and the texture doesn't change density across a seam. const float uv_area = area_2d(chart.uvs, chart.indices); if (uv_area > 1e-12f && mesh_area_3d > 1e-12f) { const float scale = std::sqrt(mesh_area_3d / uv_area); for (Vec2f &uv : chart.uvs) uv *= scale; } Vec2f lo(std::numeric_limits::max(), std::numeric_limits::max()); Vec2f hi(std::numeric_limits::lowest(), std::numeric_limits::lowest()); for (const Vec2f &uv : chart.uvs) { lo = lo.cwiseMin(uv); hi = hi.cwiseMax(uv); } chart.min = lo; chart.size = hi - lo; } // Shelf-pack the charts side by side so they don't overlap. Overlap would be harmless for a // repeating texture but wrong for a non-tiled (decal) one, and it makes the UV editor unreadable. float total_area = 0.f; float widest = 0.f; for (const FlatChart &chart : charts) { total_area = total_area + chart.size.x() * chart.size.y(); widest = std::max(widest, chart.size.x()); } const float shelf_width = std::max(widest, std::sqrt(std::max(total_area, 0.f)) * 1.4f); // Negative padding means auto: a small fraction of the packed size, which is scale-independent // and so does something sensible for a 5 mm patch and a 500 mm one alike. const float margin = (padding_mm >= 0.f) ? padding_mm : std::max(shelf_width * 0.02f, 1e-4f); std::vector order(charts.size()); std::iota(order.begin(), order.end(), 0); std::sort(order.begin(), order.end(), // tallest first: the usual way to keep shelves tight [&charts](int a, int b) { return charts[size_t(a)].size.y() > charts[size_t(b)].size.y(); }); float cursor_x = 0.f, cursor_y = 0.f, row_height = 0.f; for (const int c : order) { FlatChart &chart = charts[size_t(c)]; if (chart.uvs.empty()) continue; if (cursor_x > 0.f && cursor_x + chart.size.x() > shelf_width) { cursor_x = 0.f; cursor_y += row_height + margin; row_height = 0.f; } const Vec2f translation = Vec2f(cursor_x, cursor_y) - chart.min; for (Vec2f &uv : chart.uvs) uv += translation; cursor_x += chart.size.x() + margin; row_height = std::max(row_height, chart.size.y()); } // Concatenate the packed charts into the flat, duplicated-vertex form PatchUnwrap describes. result.chart_centroid.assign(size_t(chart_count), Vec2f::Zero()); for (int c = 0; c < chart_count; ++c) { FlatChart &chart = charts[size_t(c)]; const int base = int(result.uvs.size()); result.uvs.insert(result.uvs.end(), chart.uvs.begin(), chart.uvs.end()); result.source_vertex.insert(result.source_vertex.end(), chart.to_patch.begin(), chart.to_patch.end()); result.vertex_chart.insert(result.vertex_chart.end(), chart.uvs.size(), c); for (const stl_triangle_vertex_indices &tri : chart.indices) result.indices.emplace_back(tri[0] + base, tri[1] + base, tri[2] + base); result.source_face.insert(result.source_face.end(), chart.faces.begin(), chart.faces.end()); if (!chart.uvs.empty()) { Vec2f sum = Vec2f::Zero(); for (const Vec2f &uv : chart.uvs) sum += uv; result.chart_centroid[size_t(c)] = sum / float(chart.uvs.size()); } } result.chart_count = chart_count; // Island outlines: an edge used by exactly one triangle. Charts have disjoint vertex sets (seam // vertices are duplicated), so an edge along a seam shows up once in each chart and is correctly // reported as a boundary of both. { std::unordered_map edge_use; edge_use.reserve(result.indices.size() * 3); for (const stl_triangle_vertex_indices &tri : result.indices) for (int i = 0; i < 3; ++i) ++edge_use[undirected_edge_key(tri[i], tri[(i + 1) % 3])]; for (const stl_triangle_vertex_indices &tri : result.indices) for (int i = 0; i < 3; ++i) { const int a = tri[i], b = tri[(i + 1) % 3]; if (edge_use[undirected_edge_key(a, b)] == 1) result.boundary_edges.emplace_back(a, b); } } { std::lock_guard lock(g_unwrap_cache.mutex); // A patch changes on every paint stroke, so this would grow without bound over a session. // Nothing here is worth an LRU: the working set is "the patch I am editing right now". if (g_unwrap_cache.entries.size() >= UNWRAP_CACHE_MAX_ENTRIES) g_unwrap_cache.entries.clear(); g_unwrap_cache.entries.emplace(key, result); } return result; } Eigen::Matrix island_transform_matrix(int chart, const PatchUnwrap &unwrap, const std::vector &islands) { Eigen::Matrix m; m << 1.f, 0.f, 0.f, 0.f, 1.f, 0.f; if (chart < 0 || size_t(chart) >= islands.size() || size_t(chart) >= unwrap.chart_centroid.size()) return m; // no hand placement for this island: leave it where the packing put it const TextureIsland &island = islands[size_t(chart)]; const Vec2f centre = unwrap.chart_centroid[size_t(chart)]; const float rad = island.rotation_deg * float(M_PI) / 180.f; const float s = island.scale; const float cs = std::cos(rad) * s; const float sn = std::sin(rad) * s; // uv -> centre + R*S*(uv - centre) + offset, with the linear part and the constant part split // out so this can be handed to a shader as-is. Eigen::Matrix2f linear; linear << cs, -sn, sn, cs; const Vec2f translation = centre + island.offset - linear * centre; m.block<2, 2>(0, 0) = linear; m.col(2) = translation; return m; } Vec2f apply_island_transform(const Vec2f &uv, int chart, const PatchUnwrap &unwrap, const std::vector &islands) { const Eigen::Matrix m = island_transform_matrix(chart, unwrap, islands); return m.block<2, 2>(0, 0) * uv + m.col(2); } namespace { // One chart's copy of a shared mesh edge: the uv-vertex indices of its two endpoints, ordered so that // .first is always the lower-numbered base vertex (so two charts' copies line up by base vertex). struct ChartEdge { int chart = -1; int uv_lo = -1; int uv_hi = -1; }; // base edge (lo,hi mesh vertex) -> every chart that has it on its boundary. A mesh edge shared by two // charts is a seam between them and appears once in each chart's boundary (charts have disjoint uv // vertices), so its entry lists both charts. std::map, std::vector> build_shared_edges(const PatchUnwrap &u) { std::map, std::vector> edges; for (const auto &[ua, ub] : u.boundary_edges) { if (ua < 0 || ub < 0 || size_t(ua) >= u.source_vertex.size() || size_t(ub) >= u.source_vertex.size()) continue; const int ba = u.source_vertex[size_t(ua)], bb = u.source_vertex[size_t(ub)]; if (ba < 0 || bb < 0 || ba == bb) continue; const int chart = (size_t(ua) < u.vertex_chart.size()) ? u.vertex_chart[size_t(ua)] : -1; if (chart < 0) continue; const std::pair key{ std::min(ba, bb), std::max(ba, bb) }; // Order the uv endpoints to match the base-vertex order in the key. ChartEdge e{ chart, ua, ub }; if (u.source_vertex[size_t(ua)] != key.first) std::swap(e.uv_lo, e.uv_hi); edges[key].push_back(e); } return edges; } // Solve the placement that maps child's raw uv edge (rc0->rc1) onto the parent's placed edge // (pp0->pp1): a rigid rotation about the child chart's centroid plus an offset, scale kept at 1 so the // texel density is unchanged. cen is the child chart's centroid. TextureIsland solve_edge_alignment(const Vec2f &rc0, const Vec2f &rc1, const Vec2f &pp0, const Vec2f &pp1, const Vec2f &cen) { const Vec2f dP = pp1 - pp0, dC = rc1 - rc0; const float rho = std::atan2(dP.y(), dP.x()) - std::atan2(dC.y(), dC.x()); const float cs = std::cos(rho), sn = std::sin(rho); const auto rot = [&](const Vec2f &v) { return Vec2f(v.x() * cs - v.y() * sn, v.x() * sn + v.y() * cs); }; TextureIsland island; island.scale = 1.f; island.rotation_deg = rho * 180.f / float(M_PI); island.offset = pp0 - (rot(rc0 - cen) + cen); // island_transform_matrix places rc0 exactly here return island; } } // namespace bool join_chart_placement(const PatchUnwrap &unwrap, const std::vector &islands, int child, int parent, TextureIsland &out_child) { if (child < 0 || parent < 0 || child == parent || size_t(child) >= unwrap.chart_centroid.size()) return false; const auto edges = build_shared_edges(unwrap); for (const auto &[base_edge, list] : edges) { const ChartEdge *pe = nullptr; const ChartEdge *ce = nullptr; for (const ChartEdge &e : list) { if (e.chart == parent) pe = &e; if (e.chart == child) ce = &e; } if (pe == nullptr || ce == nullptr) continue; // Parent's *placed* edge; child's *raw* edge, matched endpoint-to-endpoint by base vertex. const Vec2f pp0 = apply_island_transform(unwrap.uvs[size_t(pe->uv_lo)], parent, unwrap, islands); const Vec2f pp1 = apply_island_transform(unwrap.uvs[size_t(pe->uv_hi)], parent, unwrap, islands); out_child = solve_edge_alignment(unwrap.uvs[size_t(ce->uv_lo)], unwrap.uvs[size_t(ce->uv_hi)], pp0, pp1, unwrap.chart_centroid[size_t(child)]); return true; } return false; } namespace { using Tri2 = std::array; // Whether two triangles overlap by more than `eps` (separating axis test). Triangles that merely share an edge or // a corner, as neighbours in a net do, do not. bool triangles_overlap(const Tri2 &a, const Tri2 &b, float eps) { for (const Tri2 *t : { &a, &b }) for (int i = 0; i < 3; ++i) { const Vec2f edge = (*t)[(i + 1) % 3] - (*t)[i]; const float len = edge.norm(); if (len < 1e-12f) continue; const Vec2f axis(-edge.y() / len, edge.x() / len); float a_min = std::numeric_limits::max(), a_max = std::numeric_limits::lowest(); float b_min = a_min, b_max = a_max; for (int k = 0; k < 3; ++k) { const float pa = axis.dot(a[k]), pb = axis.dot(b[k]); a_min = std::min(a_min, pa); a_max = std::max(a_max, pa); b_min = std::min(b_min, pb); b_max = std::max(b_max, pb); } if (a_max <= b_min + eps || b_max <= a_min + eps) return false; } return true; } // The triangles already placed in one net, bucketed in a uniform grid so a candidate chart is only tested against // its neighbourhood. Triangles spanning many cells go into a list that is tested against everything instead. struct NetGrid { static constexpr int BIG_SPAN = 16; 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 { 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; } bool overlaps(const Tri2 &t) const { for (const Tri2 &b : big) if (triangles_overlap(t, b, eps)) 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; 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; return false; } void insert(const Tri2 &t) { int x0, y0, x1, y1; if (!range(t, x0, y0, x1, y1)) { big.push_back(t); return; } for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) cells[key(x, y)].push_back(t); } }; } // namespace std::vector compute_connected_net(const PatchUnwrap &unwrap) { const int n = std::max(unwrap.chart_count, 0); std::vector islands(static_cast(n)); if (n <= 1) return islands; // Chart adjacency, with one representative shared edge per adjacent pair. const auto edges = build_shared_edges(unwrap); struct PairEdge { ChartEdge a, b; }; std::map, PairEdge> pair_edge; std::vector> adj(static_cast(n)); 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) { const int c1 = list[i].chart, c2 = list[j].chart; 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); } } // 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); for (size_t i = 0; i < unwrap.uvs.size(); ++i) if (const int c = unwrap.vertex_chart[i]; c >= 0 && c < n) chart_verts[size_t(c)].push_back(int(i)); float extent_sum = 0.f, extent = 0.f; for (size_t t = 0; t < unwrap.indices.size(); ++t) { const stl_triangle_vertex_indices &tri = unwrap.indices[t]; const int c = unwrap.vertex_chart[size_t(tri[0])]; if (c < 0 || c >= n) continue; chart_tris[size_t(c)].push_back(int(t)); const Vec2f &p0 = unwrap.uvs[size_t(tri[0])], &p1 = unwrap.uvs[size_t(tri[1])], &p2 = unwrap.uvs[size_t(tri[2])]; const Vec2f e0 = p1 - p0, e1 = p2 - p0; chart_area[size_t(c)] += 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x()); const Vec2f size = p0.cwiseMax(p1).cwiseMax(p2) - p0.cwiseMin(p1).cwiseMin(p2); extent_sum += std::max(size.x(), size.y()); extent = std::max({ extent, p0.cwiseAbs().maxCoeff(), p1.cwiseAbs().maxCoeff(), p2.cwiseAbs().maxCoeff() }); } const float cell = std::max(extent_sum / float(std::max(unwrap.indices.size(), 1)), 1e-6f); // Touching neighbours may overlap by rounding: a fraction of a typical triangle, but at least what float // rounding of a placement at this distance from the origin can produce. const float eps = std::max(0.02f * cell, 4e-6f * extent); const auto placed = [&](const Eigen::Matrix &m, int t) { const stl_triangle_vertex_indices &tri = unwrap.indices[size_t(t)]; Tri2 out; for (int k = 0; k < 3; ++k) out[size_t(k)] = m.block<2, 2>(0, 0) * unwrap.uvs[size_t(tri[k])] + m.col(2); return out; }; // Grow a net from the largest chart not yet in one, unfolding each neighbour onto the chart it was reached from // (bigger neighbours first, so slivers don't claim the good spots) unless its triangles would overlap the net. // A chart that doesn't fit stays out and roots a net of its own later, so nothing is left in a random spot. std::vector by_area(static_cast(n)); std::iota(by_area.begin(), by_area.end(), 0); std::stable_sort(by_area.begin(), by_area.end(), [&chart_area](int a, int b) { return chart_area[size_t(a)] > chart_area[size_t(b)]; }); std::vector net_of(static_cast(n), -1); int net_count = 0; for (const int root : by_area) { if (net_of[size_t(root)] >= 0 || chart_tris[size_t(root)].empty()) continue; const int net = net_count++; NetGrid grid{ cell, eps, {}, {} }; net_of[size_t(root)] = net; { const Eigen::Matrix m = island_transform_matrix(root, unwrap, islands); for (const int t : chart_tris[size_t(root)]) grid.insert(placed(m, t)); } std::queue q; q.push(root); while (!q.empty()) { const int p = q.front(); 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) { 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) }); if (it == pair_edge.end()) continue; const ChartEdge &pe = (p < c) ? it->second.a : it->second.b; const ChartEdge &ce = (p < c) ? it->second.b : it->second.a; const Vec2f pp0 = apply_island_transform(unwrap.uvs[size_t(pe.uv_lo)], p, unwrap, islands); const Vec2f pp1 = apply_island_transform(unwrap.uvs[size_t(pe.uv_hi)], p, unwrap, islands); islands[size_t(c)] = solve_edge_alignment(unwrap.uvs[size_t(ce.uv_lo)], unwrap.uvs[size_t(ce.uv_hi)], pp0, pp1, unwrap.chart_centroid[size_t(c)]); const Eigen::Matrix m = island_transform_matrix(c, unwrap, islands); std::vector tris; tris.reserve(chart_tris[size_t(c)].size()); bool fits = true; for (const int t : chart_tris[size_t(c)]) { tris.push_back(placed(m, t)); if (grid.overlaps(tris.back())) { fits = false; break; } } if (!fits) { islands[size_t(c)] = TextureIsland{}; continue; } for (const Tri2 &t : tris) grid.insert(t); net_of[size_t(c)] = net; q.push(c); } } } // Shelf-pack the nets side by side, tallest first, the way compute_patch_unwrap() packs charts. std::vector lo(static_cast(net_count), Vec2f::Constant(std::numeric_limits::max())); std::vector hi(static_cast(net_count), Vec2f::Constant(std::numeric_limits::lowest())); for (int c = 0; c < n; ++c) { const int net = net_of[size_t(c)]; if (net < 0) continue; const Eigen::Matrix m = island_transform_matrix(c, unwrap, islands); for (const int v : chart_verts[size_t(c)]) { const Vec2f p = m.block<2, 2>(0, 0) * unwrap.uvs[size_t(v)] + m.col(2); lo[size_t(net)] = lo[size_t(net)].cwiseMin(p); hi[size_t(net)] = hi[size_t(net)].cwiseMax(p); } } float total_area = 0.f, widest = 0.f; for (int k = 0; k < net_count; ++k) { const Vec2f size = hi[size_t(k)] - lo[size_t(k)]; total_area += size.x() * size.y(); widest = std::max(widest, size.x()); } const float shelf_width = std::max(widest, std::sqrt(std::max(total_area, 0.f)) * 1.4f); const float margin = std::max(shelf_width * 0.02f, 1e-4f); std::vector order(static_cast(net_count)); std::iota(order.begin(), order.end(), 0); std::stable_sort(order.begin(), order.end(), [&](int a, int b) { return hi[size_t(a)].y() - lo[size_t(a)].y() > hi[size_t(b)].y() - lo[size_t(b)].y(); }); std::vector shift(static_cast(net_count), Vec2f::Zero()); float cursor_x = 0.f, cursor_y = 0.f, row_height = 0.f; for (const int k : order) { const Vec2f size = hi[size_t(k)] - lo[size_t(k)]; if (cursor_x > 0.f && cursor_x + size.x() > shelf_width) { cursor_x = 0.f; cursor_y += row_height + margin; row_height = 0.f; } shift[size_t(k)] = Vec2f(cursor_x, cursor_y) - lo[size_t(k)]; cursor_x += size.x() + margin; row_height = std::max(row_height, size.y()); } for (int c = 0; c < n; ++c) if (net_of[size_t(c)] >= 0) islands[size_t(c)].offset += shift[size_t(net_of[size_t(c)])]; return islands; } void average_island_scales(std::vector &islands) { if (islands.empty()) return; float sum = 0.f; for (const TextureIsland &island : islands) sum += island.scale; const float mean = sum / float(islands.size()); for (TextureIsland &island : islands) island.scale = mean; } std::vector apply_lscm_uv_overrides(PatchUnwrap &unwrap, const std::vector> &overrides) { std::vector edited(unwrap.uvs.size(), false); if (overrides.empty()) return edited; // Mesh-vertex keys first, so an edit on one copy of the same vertex takes precedence over them. std::map by_mesh_vertex; for (const auto &[key, uv] : overrides) if (key >= 0) by_mesh_vertex[key] = uv; if (!by_mesh_vertex.empty()) for (size_t i = 0; i < unwrap.uvs.size(); ++i) if (const auto it = by_mesh_vertex.find(unwrap.source_vertex[i]); it != by_mesh_vertex.end()) { unwrap.uvs[i] = it->second; edited[i] = true; } for (const auto &[key, uv] : overrides) if (const int i = -key - 1; key < 0 && size_t(i) < unwrap.uvs.size()) { unwrap.uvs[size_t(i)] = uv; edited[size_t(i)] = true; } return edited; } std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer) { // Padding disabled (0), matching the UV editor: the packed islands the editor shows and the ones the // bake/preview samples must be laid out identically, or a hand placement made in the editor would // land somewhere else in the baked result. const PatchUnwrap unwrap = compute_patch_unwrap(patch, layer.lscm_seam_angle_deg, 0.f, layer.lscm_seam_edges); if (unwrap.empty()) return {}; // Manual UV edits (UV editor Vertex/Edge modes) replace the automatic raw unwrap coordinate, before the island // transform - so the edit rides along with any island move/rotate exactly like the rest of the island. PatchUnwrap edited_unwrap = unwrap; const std::vector edited = apply_lscm_uv_overrides(edited_unwrap, layer.lscm_uv_overrides); // One UV per patch vertex: a seam vertex has several (one per chart it touches) and has to // settle on one, since it can only be displaced to a single position. See compute_lscm_uvs()'s // header comment - the surface stays watertight regardless. A copy that was edited by hand wins, // so the edit is what bakes; otherwise the first copy does. std::vector per_vertex(patch.vertices.size(), Vec2f::Zero()); std::vector assigned(patch.vertices.size(), false); for (const bool edited_pass : { true, false }) for (size_t i = 0; i < edited_unwrap.uvs.size(); ++i) { const int pv = edited_unwrap.source_vertex[i]; if (pv < 0 || assigned[size_t(pv)] || edited[i] != edited_pass) continue; per_vertex[size_t(pv)] = apply_island_transform(edited_unwrap.uvs[i], edited_unwrap.vertex_chart[i], unwrap, layer.islands); assigned[size_t(pv)] = true; } return per_vertex; } std::vector compute_lscm_corner_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer) { // Padding 0 and the layer's own seam angle/edges, exactly as compute_lscm_uvs() does - the two must // unwrap identically or a hand placement would land in one place on screen and another in the bake. const PatchUnwrap unwrap = compute_patch_unwrap(patch, layer.lscm_seam_angle_deg, 0.f, layer.lscm_seam_edges); if (unwrap.empty() || unwrap.source_face.size() != unwrap.indices.size()) return {}; PatchUnwrap edited_unwrap = unwrap; apply_lscm_uv_overrides(edited_unwrap, layer.lscm_uv_overrides); // No first-copy-wins collapse here: the unwrap's triangles are already per chart, so each corner // simply takes its own chart's copy. A triangle the unwrap dropped (a sliver a chart rejected) keeps // the zero it was initialised with; the callers treat that as "no placement" the same way they treat // an empty result. std::vector corner(patch.indices.size() * 3, Vec2f::Zero()); for (size_t t = 0; t < edited_unwrap.indices.size(); ++t) { const int f = edited_unwrap.source_face[t]; if (f < 0 || size_t(f) >= patch.indices.size()) continue; const stl_triangle_vertex_indices &tri = edited_unwrap.indices[t]; for (int k = 0; k < 3; ++k) { const int uvi = tri[k]; if (uvi < 0 || size_t(uvi) >= edited_unwrap.uvs.size()) continue; // The island transform is taken against the *unedited* unwrap, whose chart_centroid is the // pivot the UV editor rotates about - same as compute_lscm_uvs(). corner[size_t(f) * 3 + size_t(k)] = apply_island_transform(edited_unwrap.uvs[size_t(uvi)], edited_unwrap.vertex_chart[size_t(uvi)], unwrap, layer.islands); } } return corner; } namespace { // apply_uv_transform()'s per-layer constants, worked out once. Triplanar sampling runs the transform // three times per point, and recomputing the rotation's cos/sin and the tiling reciprocal on every one // of them was most of the per-sample arithmetic. struct UVTransform { float scale, cs, sn, aspect; Vec2f offset; UVTransform(const TextureDisplacementLayer &layer, float aspect_) { scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; const float rad = layer.rotation_deg * float(M_PI) / 180.f; cs = std::cos(rad); sn = std::sin(rad); aspect = aspect_; offset = layer.offset; } Vec2f operator()(const Vec2f &planar) const { const Vec2f scaled = planar * scale; Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); // See apply_uv_transform() for why the aspect correction follows the rotation. if (aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; return rotated + offset; } }; // |n|^TRIPLANAR_BLEND_SHARPNESS per component. The exponent is a compile-time 4, so two squarings do // what three std::pow calls per sample did. static_assert(TRIPLANAR_BLEND_SHARPNESS == 4.f, "triplanar_weights() hard-codes the fourth power"); inline Vec3f triplanar_weights(const Vec3f &normal) { Vec3f w = normal.cwiseAbs(); w = w.cwiseProduct(w); return w.cwiseProduct(w); } } // namespace Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect) { const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; const Vec2f scaled = planar * scale; const float rad = layer.rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad); const float sn = std::sin(rad); Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); // Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its // proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out // half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is // `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so // dividing v by that extent is the same as multiplying it by width / height. A square texture has // aspect 1 and is untouched, which is why this changes nothing for the shipped library. // // Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a // shear, and doing it the other way round would make "Rotation" skew the pattern instead of // turning it. if (aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; return rotated + layer.offset; } float blend_displacement(float accumulated, float value, TextureBlendMode mode) { switch (mode) { case TextureBlendMode::Subtract: return accumulated - value; // Multiply/Divide scale rather than offset, so they take `value` as a factor relative to 1 mm // (see TextureBlendMode): a 1 mm-deep layer sampling a white texel is then exactly neutral. case TextureBlendMode::Multiply: return accumulated * value; case TextureBlendMode::Divide: { // Every height map has black regions, and a black texel samples to *exactly* zero - so this // divisor really does hit zero in ordinary use, not just in some contrived edge case. Floor // its magnitude: an unbounded 1/0 would not merely look wrong, it would fling vertices // thousands of mm away and poison the mesh's bounding box (and with it every plate/print // volume check downstream). The floor doubles as a cap on how far Divide can ever amplify // the relief beneath it - at most 1/0.05 = 20x. constexpr float min_divisor = 0.05f; const float divisor = (std::abs(value) < min_divisor) ? std::copysign(min_divisor, value < 0.f ? -1.f : 1.f) : value; return accumulated / divisor; } case TextureBlendMode::Add: default: return accumulated + value; } } bool project_uv_projective(const std::array &m, const Vec3f &position, Vec2f &uv) { const float x = position.x(), y = position.y(), z = position.z(); const float w = m[8] * x + m[9] * y + m[10] * z + m[11]; // Strictly greater than zero: at w == 0 the point sits on the projector's plane and maps to // infinity, and at w < 0 it is behind the projector, where dividing yields a plausible-looking // but mirrored uv - the classic way a projected decal reappears on the back of a model. if (!(w > 1e-6f)) return false; uv = Vec2f((m[0] * x + m[1] * y + m[2] * z + m[3]) / w, (m[4] * x + m[5] * y + m[6] * z + m[7]) / w); return true; } float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, const Vec3f &position, const Vec3f &normal, const Vec3f &patch_center, const Vec3f &patch_axis, const Vec2f *lscm_uv) { if (texture.empty()) return 0.f; // width / height of the height map, so a non-square image keeps its proportions (see // apply_uv_transform()). Every projection except the projective "from view" one funnels through // here, so this one line is what makes them all aspect-correct. const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; const UVTransform xf(layer, aspect); auto sample_at = [&](const Vec2f &planar) { return texture.sample(xf(planar), layer.tile_enabled, layer.tile_method); }; // Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the // caller only passes it when the patch actually parameterized successfully, so a patch that // failed to unwrap falls through to the analytic methods below as its documented fallback. if (lscm_uv != nullptr) return sample_at(*lscm_uv); switch (layer.projection_method) { case TextureProjectionMethod::Cylindrical: return sample_at(project_cylindrical(position, patch_center, patch_axis)); case TextureProjectionMethod::Spherical: return sample_at(project_spherical(position, patch_center)); case TextureProjectionMethod::ViewProjected: if (layer.view_project_projective) { // Exact projective placement written by the projection-frame overlay. Sampled directly, // *without* apply_uv_transform(): the matrix already maps the window's border to the uv // unit square, so the tiling/rotation/offset controls would displace it off the frame // the user just aligned. A point behind the projector has no uv at all -> no height. Vec2f uv; if (!project_uv_projective(layer.view_project_matrix, position, uv)) return 0.f; return texture.sample(uv, layer.tile_enabled, layer.tile_method); } // Flat projection onto the captured projector plane. Single-valued per point, so unlike // blended triplanar it is one sample, and it is what "project from view" places. return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::Triplanar: default: break; } // Blended tri-planar: sample all three axis-aligned planes and cross-fade between them by the // normal's own components, instead of hard-switching to whichever single axis dominates. The // hard switch is what produced a visible seam wherever the dominant axis flips (see // TextureProjectionMethod::Triplanar); a weighted blend is continuous across that transition // by construction, since the weight of the axis being left behind falls smoothly to zero. Vec3f w = triplanar_weights(normal); const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) // Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either. return sample_at(Vec2f(position.x(), position.y())); w /= w_sum; // Each plane drops the axis it is named for, matching project_planar()'s own convention (which // the GUI's on-canvas placement gizmo also relies on). return w.x() * sample_at(Vec2f(position.y(), position.z())) + w.y() * sample_at(Vec2f(position.x(), position.z())) + w.z() * sample_at(Vec2f(position.x(), position.y())); } bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, const Vec3f &position, const Vec3f &normal, Vec3f &out, const Vec3f &patch_center, const Vec3f &patch_axis, const Vec2f *lscm_uv) { if (!texture.has_color()) return false; // Deliberately a transcription of sample_layer_height()'s dispatch rather than a shared template: // the two differ in what "nothing here" means. Height returns 0, which is a perfectly good height // (no displacement); colour has no such neutral value - black is a colour - so every path that // returns 0 there has to report false here instead, and the caller leaves the triangle uncoloured. const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; const UVTransform xf(layer, aspect); auto sample_at = [&](const Vec2f &planar) { return texture.sample_color(xf(planar), layer.tile_enabled, layer.tile_method); }; // Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the // height. Checked explicitly because sample_color() reports it as black, which is a real colour. auto covered = [&](const Vec2f &planar) { if (layer.tile_enabled) return true; const Vec2f uv = xf(planar); return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f; }; if (lscm_uv != nullptr) { if (!covered(*lscm_uv)) return false; out = sample_at(*lscm_uv); return true; } switch (layer.projection_method) { case TextureProjectionMethod::Cylindrical: { const Vec2f p = project_cylindrical(position, patch_center, patch_axis); if (!covered(p)) return false; out = sample_at(p); return true; } case TextureProjectionMethod::Spherical: { const Vec2f p = project_spherical(position, patch_center); if (!covered(p)) return false; out = sample_at(p); return true; } case TextureProjectionMethod::ViewProjected: if (layer.view_project_projective) { // The frame's own rectangle is the placement, so no apply_uv_transform() - see // sample_layer_height(). A point behind the projector has no uv, hence no colour. Vec2f uv; if (!project_uv_projective(layer.view_project_matrix, position, uv)) return false; if (!layer.tile_enabled && (uv.x() < 0.f || uv.x() >= 1.f || uv.y() < 0.f || uv.y() >= 1.f)) return false; out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); return true; } else { const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); if (!covered(p)) return false; out = sample_at(p); return true; } case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::Triplanar: default: break; } // Blended tri-planar, weighted exactly as the height is, so colour and relief stay registered // across the cross-fade band at a 90-degree edge. Vec3f w = triplanar_weights(normal); const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) { const Vec2f p(position.x(), position.y()); if (!covered(p)) return false; out = sample_at(p); return true; } w /= w_sum; // A blend of three planes is only "not covered" where *every* contributing plane is outside the // placement; where some are, the covered ones are renormalised so the colour does not fade toward // black at the edge of an untiled tri-planar layer. const std::array planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), Vec2f(position.x(), position.y()) }; Vec3f acc = Vec3f::Zero(); float acc_w = 0.f; for (int i = 0; i < 3; ++i) if (w[i] > 0.f && covered(planes[size_t(i)])) { acc += w[i] * sample_at(planes[size_t(i)]); acc_w += w[i]; } if (acc_w <= 0.f) return false; out = acc / acc_w; return true; } indexed_triangle_set extract_painted_patch(const indexed_triangle_set &base_mesh, const TriangleSelector::TriangleSplittingData &facet_data) { if (facet_data.triangles_to_split.empty()) return {}; const TriangleMesh selector_mesh(base_mesh); TriangleSelector selector(selector_mesh); selector.deserialize(facet_data, false); return selector.get_facets_strict(EnforcerBlockerType::ENFORCER); } bool compute_layer_paint_anchor(const indexed_triangle_set &base_mesh, const TriangleSelector::TriangleSplittingData &facet_data, Vec3f &anchor_pos, Vec3f &anchor_normal) { const indexed_triangle_set patch = extract_painted_patch(base_mesh, facet_data); if (patch.indices.empty()) return false; Vec3f centroid_sum = Vec3f::Zero(); Vec3f normal_sum = Vec3f::Zero(); for (const stl_triangle_vertex_indices &tri : patch.indices) { const Vec3f &a = patch.vertices[tri[0]]; const Vec3f &b = patch.vertices[tri[1]]; const Vec3f &c = patch.vertices[tri[2]]; // Cross product magnitude is twice the face area, so this area-weights both sums the same // way texture_displacement_vertex_normals() does above. normal_sum += (b - a).cross(c - a); centroid_sum += (a + b + c) / 3.f; } anchor_pos = centroid_sum / float(patch.indices.size()); anchor_normal = (normal_sum.norm() > 1e-8f) ? Vec3f(normal_sum.normalized()) : Vec3f::UnitZ(); return true; } // Area-weighted vertex normals of the undisplaced mesh. build_texture_displacement() computes // these once, up front, and every layer both projects and displaces along them - so a vertex // covered by several layers is pushed along one single, well-defined direction rather than along // whatever direction the surface happened to be pointing partway through the stack. std::vector texture_displacement_vertex_normals(const indexed_triangle_set &its) { std::vector normals(its.vertices.size(), Vec3f::Zero()); for (const stl_triangle_vertex_indices &tri : its.indices) { const Vec3f &a = its.vertices[tri[0]]; const Vec3f &b = its.vertices[tri[1]]; const Vec3f &c = its.vertices[tri[2]]; // Cross product magnitude is twice the face area, so this naturally area-weights the // contribution of each incident face to its vertices. const Vec3f area_weighted_normal = (b - a).cross(c - a); normals[tri[0]] += area_weighted_normal; normals[tri[1]] += area_weighted_normal; normals[tri[2]] += area_weighted_normal; } for (Vec3f &n : normals) { const float len = n.norm(); n = (len > 1e-8f) ? Vec3f(n / len) : Vec3f::UnitZ(); } return normals; } namespace { // Shortest along-surface distance from every patch vertex to the patch boundary (the pinned vertices // shared with the untouched surface), by a multi-source Dijkstra over the patch edges. Used for edge // smoothing: the displacement is faded out as this distance goes to zero. std::vector patch_boundary_distance(const indexed_triangle_set &patch, const std::vector &is_boundary) { const size_t n = patch.vertices.size(); std::vector>> adj(n); for (const stl_triangle_vertex_indices &tri : patch.indices) for (int i = 0; i < 3; ++i) { const int a = tri[i], b = tri[(i + 1) % 3]; if (a < 0 || b < 0 || size_t(a) >= n || size_t(b) >= n) continue; const float w = (patch.vertices[size_t(a)] - patch.vertices[size_t(b)]).norm(); adj[size_t(a)].push_back({ b, w }); adj[size_t(b)].push_back({ a, w }); } std::vector dist(n, std::numeric_limits::infinity()); using QN = std::pair; std::priority_queue, std::greater> pq; for (size_t v = 0; v < n; ++v) if (v < is_boundary.size() && is_boundary[v]) { dist[v] = 0.f; pq.push({ 0.f, int(v) }); } while (!pq.empty()) { const auto [d, u] = pq.top(); pq.pop(); if (d > dist[size_t(u)]) continue; for (const auto &[w, ew] : adj[size_t(u)]) { const float nd = d + ew; if (nd < dist[size_t(w)]) { dist[size_t(w)] = nd; pq.push({ nd, w }); } } } return dist; } } // namespace namespace { // Majority filter over face adjacency: each triangle takes the most common colour among itself and // the (up to three) triangles across its edges. Ties, and a triangle whose own colour is already the // most common, keep what they had - so the filter only ever removes a facet that disagrees with its // whole neighbourhood, and cannot drift a large region. // // Read from a snapshot of the previous pass, so the result does not depend on triangle order. // Uncoloured triangles (-1) neither vote nor get voted on: the paint boundary is not noise. void despeckle_triangle_colors(const indexed_triangle_set &mesh, std::vector &color, int passes) { if (passes <= 0 || color.size() != mesh.indices.size()) return; const std::vector neighbors = its_face_neighbors(mesh); if (neighbors.size() != mesh.indices.size()) return; std::vector prev; for (int pass = 0; pass < passes; ++pass) { prev = color; tbb::parallel_for(tbb::blocked_range(0, color.size()), [&](const tbb::blocked_range &range) { for (size_t i = range.begin(); i < range.end(); ++i) { if (prev[i] < 0) continue; // At most four candidates (self plus three neighbours), so counting by a linear scan // is cheaper than any map. int cand[4] = { prev[i], -1, -1, -1 }; int count[4] = { 1, 0, 0, 0 }; int n = 1; for (int e = 0; e < 3; ++e) { const int nb = neighbors[i][e]; if (nb < 0 || size_t(nb) >= prev.size() || prev[size_t(nb)] < 0) continue; const int c = prev[size_t(nb)]; int k = 0; for (; k < n; ++k) if (cand[k] == c) { ++count[k]; break; } if (k == n && n < 4) { cand[n] = c; count[n] = 1; ++n; } } // Strictly greater, so a tie leaves the triangle alone. int best = 0; for (int k = 1; k < n; ++k) if (count[k] > count[best]) best = k; if (count[best] > count[0]) color[i] = cand[best]; } }); } } } // namespace void merge_small_color_regions(const indexed_triangle_set &mesh, std::vector &color, float min_area_mm2) { const size_t n = mesh.indices.size(); if (min_area_mm2 <= 0.f || color.size() != n) return; const std::vector neighbors = its_face_neighbors(mesh); if (neighbors.size() != n) return; const auto edge_length = [&mesh](size_t f, int e) { const stl_triangle_vertex_indices &t = mesh.indices[f]; return (mesh.vertices[size_t(t[(e + 1) % 3])] - mesh.vertices[size_t(t[e])]).norm(); }; // Connected components of equal colour: `faces` lists every coloured face, component by // component, `start` delimits them. Uncoloured faces (-1) belong to no component and block the // flood, so a region never grows across the paint's border. std::vector component(n, -1); std::vector faces; std::vector start; std::vector area; std::vector stack; faces.reserve(n); for (size_t seed = 0; seed < n; ++seed) { if (color[seed] < 0 || component[seed] >= 0) continue; const int c = color[seed]; const int id = int(area.size()); start.push_back(faces.size()); area.push_back(0.f); component[seed] = id; stack.push_back(int(seed)); while (!stack.empty()) { const size_t f = size_t(stack.back()); stack.pop_back(); faces.push_back(int(f)); const stl_triangle_vertex_indices &t = mesh.indices[f]; const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &cv = mesh.vertices[size_t(t[2])]; area[size_t(id)] += 0.5f * (b - a).cross(cv - a).norm(); for (int e = 0; e < 3; ++e) { const int nb = neighbors[f][e]; if (nb < 0 || size_t(nb) >= n || component[size_t(nb)] >= 0 || color[size_t(nb)] != c) continue; component[size_t(nb)] = id; stack.push_back(nb); } } } start.push_back(faces.size()); // Smallest first, so that when a small island borders a slightly larger one the larger one has // not yet moved and the small one joins whatever the two of them sit in; the larger one then // reads that colour in turn. std::vector order; for (int id = 0; id < int(area.size()); ++id) if (area[size_t(id)] < min_area_mm2) order.push_back(id); std::sort(order.begin(), order.end(), [&area](int l, int r) { return area[size_t(l)] < area[size_t(r)]; }); std::vector> weights; // neighbouring colour -> shared edge length for (const int id : order) { const size_t begin = start[size_t(id)], end = start[size_t(id) + 1]; const int own = color[size_t(faces[begin])]; weights.clear(); for (size_t k = begin; k < end; ++k) { const size_t f = size_t(faces[k]); for (int e = 0; e < 3; ++e) { const int nb = neighbors[f][e]; if (nb < 0 || size_t(nb) >= n) continue; const int c = color[size_t(nb)]; // read now: an earlier merge may have recoloured it if (c < 0 || c == own) continue; const float len = edge_length(f, e); auto it = std::find_if(weights.begin(), weights.end(), [c](const std::pair &w) { return w.first == c; }); if (it == weights.end()) weights.emplace_back(c, len); else it->second += len; } } if (weights.empty()) continue; // bordered only by uncoloured faces (or nothing): stays const int target = std::max_element(weights.begin(), weights.end(), [](const std::pair &l, const std::pair &r) { return l.second < r.second; })->first; for (size_t k = begin; k < end; ++k) color[size_t(faces[k])] = target; } } namespace { // Wired to the same layer stack via make_combined_displacement_sampler(), so layers, blend modes and // projections behave identically in both paths and the comparison is between the meshing strategies. indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set &mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, const TextureDisplacementOptions &options, const DisplacementProgressFn &progress, const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, TextureBakeStats *stats) { HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data); if (!combined) return mesh; // nothing decodable to displace with // Unpainted triangles are excluded, keeping them out of refinement and pinned thereafter. The // paint is finer than that, though: a brush stroke splits a source triangle into pieces, and only // some of them are painted. `painted_pieces` keeps every layer's painted pieces (they lie in the // source surface) so the refined faces can be tested against the paint itself, not against the // source triangle they came from. std::vector excluded(mesh.indices.size(), 1); indexed_triangle_set painted_pieces; { const TriangleMesh selector_mesh(mesh); TriangleSelector selector(selector_mesh); bool dirty = false; for (const TriangleSelector::TriangleSplittingData &data : facets_data) { if (data.triangles_to_split.empty()) continue; selector.deserialize(data, dirty); dirty = true; std::vector piece_src; const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, &piece_src); for (const int src : piece_src) if (src >= 0 && size_t(src) < excluded.size()) excluded[size_t(src)] = 0; // `patch` carries the whole mesh's vertex array (see compact_patch_with_map()); append // only what its pieces reference. std::vector unused; const indexed_triangle_set compact = compact_patch_with_map(patch, unused); const int offset = int(painted_pieces.vertices.size()); painted_pieces.vertices.insert(painted_pieces.vertices.end(), compact.vertices.begin(), compact.vertices.end()); for (const stl_triangle_vertex_indices &t : compact.indices) painted_pieces.indices.emplace_back(t[0] + offset, t[1] + offset, t[2] + offset); } } if (std::all_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }) || painted_pieces.indices.empty()) return mesh; // nothing painted // Distance to the nearest painted piece. Built once here; the tree is read-only afterwards, so // the parallel stages below share it freely. const AABBTreeIndirect::Tree3f painted_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(painted_pieces.vertices, painted_pieces.indices); // `foot`/`normal`, when asked for, are the closest point on the painted pieces and that piece's // normal. The pieces lie in the *undisplaced* surface, so for a displaced point those two are the // base position and normal underneath it - the frame colour has to be projected in (see below). const auto painted_closest = [&painted_pieces, &painted_tree](const Vec3f &p, Vec3f *foot, Vec3f *normal) { size_t hit = 0; Vec3f hit_point; const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set( painted_pieces.vertices, painted_pieces.indices, painted_tree, p, hit, hit_point); if (foot != nullptr) *foot = hit_point; if (normal != nullptr && hit < painted_pieces.indices.size()) { const stl_triangle_vertex_indices &t = painted_pieces.indices[hit]; const Vec3f &a = painted_pieces.vertices[size_t(t[0])], &b = painted_pieces.vertices[size_t(t[1])], &c = painted_pieces.vertices[size_t(t[2])]; Vec3f n = (b - a).cross(c - a); const float l = n.norm(); *normal = (l > 0.f) ? Vec3f(n / l) : Vec3f::UnitZ(); } return d2; }; const auto painted_dist2 = [&painted_closest](const Vec3f &p) { return painted_closest(p, nullptr, nullptr); }; // Before displacement the queried centroids lie in the same surface as the pieces, so anything // beyond a hair is genuinely outside the paint. constexpr float paint_tol = 0.05f; const auto painted_at = [&painted_dist2](const Vec3f &p) { return painted_dist2(p) < paint_tol * paint_tol; }; // "Auto" resolution and budget (0 and -1) resolve here, from the texture and the model - the mesh // is already in world mm at this point, so no transform is needed. const bool auto_edge = options.v2_refine_mm <= 0.f, auto_budget = options.v2_max_triangles_k < 0; const V2Resolution rec = (auto_edge || auto_budget) ? recommend_v2_resolution(mesh, layers) : V2Resolution{}; TextureBake::PipelineSettings settings; settings.refine_length = auto_edge ? std::max(0.05, double(rec.edge_mm)) : std::max(0.01f, options.v2_refine_mm); settings.regularize = options.v2_regularize; settings.max_triangles = size_t(auto_budget ? std::max(0, rec.budget_k) : options.v2_max_triangles_k) * 1000; BOOST_LOG_TRIVIAL(info) << "TextureBake resolution: " << settings.refine_length << " mm" << (auto_edge ? " (auto)" : "") << ", budget " << settings.max_triangles / 1000 << " k" << (auto_budget ? " (auto)" : ""); settings.preserve_untextured = true; // Always on: relief driven under the plate is unprintable whichever pipeline produced it, so this // is no longer a choice the user has to make. Only geometry that ends up below the model's own // bottom is moved - downward relief that stays clear of the plate is untouched. settings.clamp_below_plate = true; settings.relocate = options.v2_relocate; settings.flip_edges = options.v2_flip_edges; // The sampler already returns millimetres, so the displacement stage must not scale it again. settings.displace.amplitude = 1.f; settings.displace.symmetric = false; // The paint decides what moves here, so the angle limits stay off. settings.displace.bottom_angle_limit = 0.f; settings.displace.top_angle_limit = 0.f; // The sampler's normal only picks the projection blend (triplanar weights); the displacement // direction is the pipeline's own smooth normal. Handing it the Laplacian-smoothed blend normal // spreads a crease's 50/50 blend over a band of rows instead of one: on a cube edge the single row // of vertices that samples both faces' patterns half and half otherwise comes out as a row of // notches, since it matches neither face. settings.displace.blend_normal_smoothing = 32; // Refined faces are asked against the paint itself, so a stroke narrower than a source triangle // moves only what it covers. // Only when some included source triangle is painted in part: the pieces then cover less area // than the triangles they came from. Whole-triangle paint (the usual case, and every bench) has // nothing to gain from a query per refined face. { const auto area_of = [](const indexed_triangle_set &its) { double a = 0.0; for (const stl_triangle_vertex_indices &t : its.indices) a += 0.5 * double((its.vertices[size_t(t[1])] - its.vertices[size_t(t[0])]) .cross(its.vertices[size_t(t[2])] - its.vertices[size_t(t[0])]).norm()); return a; }; double included_area = 0.0; for (size_t t = 0; t < mesh.indices.size(); ++t) if (excluded[t] == 0) { const stl_triangle_vertex_indices &f = mesh.indices[t]; included_area += 0.5 * double((mesh.vertices[size_t(f[1])] - mesh.vertices[size_t(f[0])]) .cross(mesh.vertices[size_t(f[2])] - mesh.vertices[size_t(f[0])]).norm()); } const double pieces_area = area_of(painted_pieces); if (pieces_area < included_area * (1.0 - 1e-4)) settings.painted = painted_at; } TextureBake::DisplaceBounds bounds; bounds.min = bounds.max = mesh.vertices.empty() ? Vec3f::Zero() : mesh.vertices.front(); for (const Vec3f &v : mesh.vertices) { bounds.min = bounds.min.cwiseMin(v); bounds.max = bounds.max.cwiseMax(v); } const auto sample = [&combined](const Vec3f &pos, const Vec3f &, const Vec3f &blend_normal) { // The blend normal chooses the projection; the move itself is along the smooth normal, which // the pipeline applies on its own. return combined(pos, blend_normal); }; // The pipeline takes its displacement direction from the soup's winding, so a mirrored placement // would drive the whole relief inwards. The paint masks were read off `mesh` above, against its // own vertex order, so the winding can only be turned round after that - here, on the copy that // becomes the soup - and has to be turned back on the way out, since the caller undoes the same // mirror when it maps the result back into the volume's coordinates. indexed_triangle_set oriented = mesh; if (flip_normals) for (stl_triangle_vertex_indices &t : oriented.indices) std::swap(t[1], t[2]); // 0 means no simplification, i.e. Bake mode. const TextureBake::PipelineMode mode = settings.max_triangles > 0 ? TextureBake::PipelineMode::Export : TextureBake::PipelineMode::Bake; // Colour, when asked for. The sampler is built now so the simplification can see the colour // boundaries: a simplified triangle must not span two colours, or its one colour is wrong over // part of it (half a tile in the neighbour's colour, a tile edge that wanders). const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize); const ColorFieldSampler color_sampler = want_color ? make_combined_color_sampler(mesh, layers, facets_data, color->quantize, color->quantize_pure) : ColorFieldSampler{}; // // The *palette* index, not the printed filament. The decimation treats any edge whose two faces // differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived** // colour changes - which is exactly what ColorResolveFn's own contract says the interleaving may // never be fed into. Handing it the resolved filament made every Z band boundary a crease: on an // upright wall that is one crease per band, so the collapse ran along those lines and left a stack // of horizontal slivers, each printing in a single filament. Those were the horizontal colour // lines in the baked result, and they also spent the triangle budget drawing a pattern the eye is // meant to blend away. Faces the paint excludes are skipped by the pipeline itself. const TextureBake::ColorSampleFn color_sample = color_sampler ? TextureBake::ColorSampleFn([&color_sampler](const Vec3f &p, const Vec3f &n) { return color_sampler(p, n); }) : TextureBake::ColorSampleFn{}; // The pipeline works on `oriented`, whose winding was reversed above for a mirrored placement, so // the stages it records are wound the same way. Note where they start and turn the whole range // back afterwards, exactly as the result itself is turned back below. const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0; TextureBake::PipelineResult result = TextureBake::run_pipeline( TextureBake::to_soup(oriented, excluded), sample, settings, bounds, mode, excluded, [&progress](const char *, double f) { return !progress || progress(std::clamp(int(f * 100.0), 0, 99)); }, debug, color_sample); if (debug != nullptr && flip_normals) debug->rebase(debug_mark, nullptr, /* flip_winding */ true); if (result.canceled || result.geometry.empty()) return {}; if (stats != nullptr) { stats->triangles_refined = result.triangles_refined; stats->triangles_out = result.geometry.triangle_count(); stats->triangles_budget = result.triangles_budget; stats->budget_limited = result.budget_limited; } indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry); if (out.indices.empty()) return mesh; if (flip_normals) for (stl_triangle_vertex_indices &t : out.indices) std::swap(t[1], t[2]); // Colour, per output triangle. The topology is new, so unlike the classic path there is no base // triangle to inherit a colour from: each output triangle samples the colour stack at its own // centroid, and takes colour only where the paint is - measured against the painted pieces, which // an output centroid is never further from than the relief depth. Then the same despeckle and // filament resolution as the classic path. if (want_color) { std::vector out_color(out.indices.size(), 0); const ColorFieldSampler &sampler = color_sampler; if (sampler) { const bool all_painted = std::none_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }); float max_depth = 0.f; for (const TextureDisplacementLayer &layer : layers) max_depth = std::max(max_depth, std::abs(layer.depth_mm)); const float relief_tol = max_depth + paint_tol; std::vector palette(out.indices.size(), -1); tbb::parallel_for(tbb::blocked_range(0, out.indices.size()), [&](const tbb::blocked_range &r) { for (size_t i = r.begin(); i < r.end(); ++i) { const stl_triangle_vertex_indices &t = out.indices[i]; const Vec3f &a = out.vertices[size_t(t[0])], &b = out.vertices[size_t(t[1])], &c = out.vertices[size_t(t[2])]; const Vec3f centroid = (a + b + c) / 3.f; // Sample on the *base* surface under this face, not on the relief. The projection // is a function of position and normal, and the displacement has moved both: the // triplanar blend weights three axis planes by |n|^4, so a face tilted ~45 degrees // away from its base normal reads the image half through an unrelated plane. The // patch border is a ring of exactly such faces - the relief ramps to zero there - // which is the coloured fringe around the border, and the steep interior slopes // streak for the same reason. The classic path samples the base patch for this very // reason; this path was the inconsistent one. Vec3f foot = centroid, base_n = Vec3f::UnitZ(); const float d2 = painted_closest(centroid, &foot, &base_n); if (!all_painted && d2 >= relief_tol * relief_tol) continue; palette[i] = sampler(foot, base_n); } }); // The despeckle filter is for a fine, uniform mesh, where one facet flipping colour is // noise. A simplified mesh is neither: its triangles are as large as the colour regions // themselves and already end on the colour boundaries, so a majority vote among three // neighbours would repaint whole features. Bake mode (no simplification) keeps it. const bool simplified = result.face_parent_id.empty(); despeckle_triangle_colors(out, palette, simplified ? 0 : color->despeckle_passes); merge_small_color_regions(out, palette, color->min_color_region_mm2); for (size_t i = 0; i < out.indices.size(); ++i) { if (palette[i] < 0) continue; const stl_triangle_vertex_indices &t = out.indices[i]; const Vec3f &a = out.vertices[size_t(t[0])], &b = out.vertices[size_t(t[1])], &c = out.vertices[size_t(t[2])]; const Vec3f centroid = (a + b + c) / 3.f; Vec3f normal = (b - a).cross(c - a); const float nl = normal.norm(); normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); const int filament = color->resolve ? color->resolve(palette[i], centroid, normal) : palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); } } *color->out_triangle = std::move(out_color); } return out; } } // namespace // The bake proper. Runs entirely in whatever space `base_mesh` is given in; the public entry point // below is what puts it in world space and brings the result back. // `flip_normals` says the mesh is wound the opposite way round from its outward direction, which is // what a mirroring world transform leaves behind: the positions are right, but every normal derived // from the winding points into the model. See build_texture_displacement(). static indexed_triangle_set build_texture_displacement_in_place( const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, const TextureDisplacementOptions &options, const DisplacementProgressFn &progress, const TextureColorRequest *color, bool flip_normals, BakeStageRecorder *debug, TextureBakeStats *stats) { // The classic path moves the vertices the mesh already has, so there is no budget to report on. (void) stats; // Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the // one result no caller can mistake for a finished bake and commit onto the volume. const auto report = [&progress](int percent) { return !progress || progress(percent); }; // Stage capture for the debug view. A stage is timed from the end of the previous one, so the // capture itself - a copy plus an edge scan - sits outside every measurement it reports. auto stage_clock = std::chrono::steady_clock::now(); const auto capture = [&](const char *name, const indexed_triangle_set &m, const std::string &detail = {}) { if (debug == nullptr) return; const double ms = std::chrono::duration(std::chrono::steady_clock::now() - stage_clock).count(); debug->capture(name, m.vertices, m.indices, ms, detail); stage_clock = std::chrono::steady_clock::now(); }; indexed_triangle_set mesh = base_mesh; // TriangleSelector's vertex array starts with the mesh's own vertices (any extra ones, created // where a brush stroke split a triangle, are appended after them), and get_facets_strict() // emits exactly the *referenced* ones, in order. So selector vertex index i is our vertex i - // but only if every vertex of `mesh` is referenced by some triangle, which is precisely what // this call establishes. It is a no-op (indices untouched) for any mesh that already is, which // in practice is all of them; it exists so an input carrying stray unreferenced vertices can't // silently shift the indexing and displace the wrong vertices. its_compactify_vertices(mesh); if (mesh.vertices.empty() || mesh.indices.empty()) return mesh; capture("input", mesh, "as handed to the bake"); if (options.pipeline_v2) return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals, debug, stats); // Layers are combined in slot order, like stacked layers in an image editor: each one folds its // own displacement into the running total via its blend mode (see TextureBlendMode). std::vector ordered_layers; for (const TextureDisplacementLayer &layer : layers) if (!layer.empty() && layer.slot >= 0 && size_t(layer.slot) < TEXTURE_DISPLACEMENT_MAX_LAYERS) ordered_layers.push_back(&layer); std::sort(ordered_layers.begin(), ordered_layers.end(), [](const TextureDisplacementLayer *a, const TextureDisplacementLayer *b) { return a->slot < b->slot; }); // Every layer measures its displacement against the *original* surface - normals included - // rather than against whatever the previous layer left behind. That is what lets all the layers // be evaluated independently and merged per vertex, instead of having to re-mesh and remap the // paint masks between them (see the header for why that earlier design was dropped). std::vector vertex_normals = texture_displacement_vertex_normals(mesh); // ... with one correction, applied where the painted area does not cover every triangle around a // vertex: there the direction to move in is the normal of the *painted* surface, not of the whole // mesh. On the rim of a fully painted top face the whole-mesh normal is the 45 degrees bisector // between the face and the side wall it meets, so displacing along it flares the rim outwards // instead of raising it. Taken over the union of every layer's paint (triangles_to_split is // exactly the set of original triangles a layer's brush touched - serialize() records an entry for // each one that is split or carries a non-default state), so a vertex still has one single // direction however many layers cover it. Interior vertices are unaffected: all their triangles // are painted, so the two normals coincide. { std::vector painted_face(mesh.indices.size(), 0); bool any_paint = false; for (const TextureDisplacementLayer *layer : ordered_layers) for (const TriangleSelector::TriangleBitStreamMapping &m : facets_data[size_t(layer->slot)].triangles_to_split) if (size_t(m.triangle_idx) < mesh.indices.size()) { painted_face[size_t(m.triangle_idx)] = 1; any_paint = true; } if (any_paint) { std::vector painted_normals(mesh.vertices.size(), Vec3f::Zero()); for (size_t i = 0; i < mesh.indices.size(); ++i) { if (!painted_face[i]) continue; const stl_triangle_vertex_indices &t = mesh.indices[i]; const Vec3f fn = (mesh.vertices[t[1]] - mesh.vertices[t[0]]).cross(mesh.vertices[t[2]] - mesh.vertices[t[0]]); for (int k = 0; k < 3; ++k) painted_normals[size_t(t[k])] += fn; // area-weighted, same convention as the full normals } for (size_t v = 0; v < vertex_normals.size(); ++v) if (const float l = painted_normals[v].norm(); l > 1e-8f) vertex_normals[v] = painted_normals[v] / l; // else: no painted triangle touches this vertex, so it will not be displaced anyway - // leave the whole-mesh normal in place rather than zeroing it. } } // Both normal passes above read their direction out of the triangle winding, so a mirrored // placement leaves every one of them pointing into the model - the relief would be carved rather // than raised. Correct them once, here, where every later stage (displacement direction, the // planar/triplanar projection axes, the cylinder axis) picks them up already right. if (flip_normals) for (Vec3f &n : vertex_normals) n = -n; if (!report(5)) return {}; std::vector displacement(mesh.vertices.size(), 0.f); // uint8_t rather than std::vector: the sampling loop below writes these from several // threads at once, and vector's bit packing makes writes to *distinct* elements a data // race on the shared word. std::vector displaced(mesh.vertices.size(), 0); // Union, over every layer, of that layer's patch border - the vertices the post-process smoothing // holds when TextureDisplacementOptions::smooth_skip_border is set. A vertex on any patch's edge // counts, which is the conservative choice: hold it rather than let one layer's smoothing melt the // rim another layer put there. std::vector on_patch_border(mesh.vertices.size(), false); bool any_displacement = false; // Colour is accumulated per *triangle*, not per vertex: it ends up in the volume's // mmu_segmentation_facets, which assigns one filament to a whole facet. Layers are visited in // ascending slot order, so a higher layer simply overwrites a lower one's colour where they // overlap - the painter's-algorithm reading of a layer stack, and the one that matches how the // panel lists them. const bool want_color = color != nullptr && color->out_triangle != nullptr && bool(color->quantize); // Palette indices, not filament indices: -1 for "no colour here". Kept in perceived-colour space // for the whole pass so the despeckle filter below operates on what the eye sees, and the // interleaving that turns a mixed entry into two real filaments happens once, at the very end. std::vector triangle_palette; if (want_color) triangle_palette.assign(mesh.indices.size(), -1); const TriangleMesh selector_mesh(mesh); // One selector for the whole stack, re-deserialized per layer. Its constructor computes // its_face_neighbors() and its_face_normals() over the *entire* mesh, which on a subdivided model // is by far the most expensive thing here - building a fresh one per layer paid that cost up to // eight times over. reset() (what deserialize(..., true) calls) only rebuilds the vertex/triangle // arrays; the neighbour and face-normal tables are immutable members and survive it. TriangleSelector selector(selector_mesh); bool selector_dirty = false; const int layer_count = std::max(int(ordered_layers.size()), 1); int layer_index = 0; for (const TextureDisplacementLayer *layer : ordered_layers) { // Progress spans 5..65% across the layers; the apply and smoothing passes take it from there. if (!report(5 + (60 * layer_index++) / layer_count)) return {}; const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)]; if (data.triangles_to_split.empty()) continue; const DecodedHeightTexture height = decode_height_texture(*layer); if (height.empty()) continue; // needs_reset only from the second layer on: the selector is already pristine on the first. selector.deserialize(data, selector_dirty); selector_dirty = true; const bool color_this_layer = want_color && layer->color_enabled; // A flat-colour image is matched against the filaments alone (see TextureColorRequest). const ColorQuantizeFn &layer_quantize = (color_this_layer && color->quantize_pure && analyze_texture_detail(*layer).flat_colors) ? color->quantize_pure : color->quantize; std::vector patch_source; // sub-triangle -> base mesh triangle, only built when colouring const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER, color_this_layer ? &patch_source : nullptr); if (patch.indices.empty()) continue; // get_facets_strict() returns the same vertex array whichever state is asked for (only the // triangles are filtered), so `patch` and `rest` share one indexing - and, per the // compactify above, it is our own. const indexed_triangle_set rest = selector.get_facets_strict(EnforcerBlockerType::NONE); // A vertex used by even one *unpainted* triangle sits on this layer's boundary. It is still // needed either way - the edge-smoothing falloff measures distance from it - but whether it is // held flat is now the user's call (TextureDisplacementOptions::displace_border), because // nothing can tear: the bake is topology-preserving, so a border vertex is one vertex shared // by both regions and moving it just tilts the unpainted triangles that use it. std::vector is_boundary(patch.vertices.size(), false); for (const stl_triangle_vertex_indices &tri : rest.indices) for (int i = 0; i < 3; ++i) { is_boundary[tri[i]] = true; if (tri[i] < int(mesh.vertices.size())) on_patch_border[size_t(tri[i])] = true; } const bool pin_boundary = !options.displace_border; // Only the Cylindrical/Spherical methods need the centroid and axis; Triplanar blends each // vertex's own normal and LSCM solves the patch globally. average_normal is also the fallback // normal the colour pass below uses for a degenerate triangle. Vec3f average_normal, patch_centroid, patch_axis; texture_displacement_patch_frame(patch, vertex_normals, patch_centroid, patch_axis, average_normal); // A real unwrap of the whole patch, computed once here rather than per vertex - it is a // per-chart solve over the whole patch, not a per-point formula. Cached, so repeating this // for every slider tweak costs a hash rather than a re-solve (see compute_patch_unwrap()). const std::vector lscm_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_uvs(patch, *layer) : std::vector{}; // The colour pass below samples per *triangle*, so it takes the per-corner unwrap instead: the // per-vertex collapse above would hand a triangle at a seam the island layout did not join its // neighbour's placement, painting one triangle per face from the wrong part of the texture. // (The displacement itself stays on lscm_uvs - a vertex has one position, so one height.) const std::vector lscm_corner_uvs = (layer->projection_method == TextureProjectionMethod::LSCM) ? compute_lscm_corner_uvs(patch, *layer) : std::vector{}; const bool corner_uv_ok = !lscm_corner_uvs.empty() && lscm_corner_uvs.size() == patch.indices.size() * 3; // Colour, if this layer carries any. Area-weighted over each base triangle's *painted* part, // so a triangle the brush only clipped a corner off takes the colour of that corner rather // than of the whole triangle's worth of texture - and so a triangle straddling a colour // boundary lands on whichever side covers more of it, instead of on whichever sub-triangle // happened to be emitted first. One quantize call per triangle, after the averaging. if (color_this_layer) { const DecodedHeightTexture &tex = height; if (tex.has_color()) { std::vector sum(mesh.indices.size(), Vec3f::Zero()); std::vector sum_area(mesh.indices.size(), 0.f); for (size_t j = 0; j < patch.indices.size() && j < patch_source.size(); ++j) { const size_t S = size_t(patch_source[j]); if (S >= mesh.indices.size()) continue; const stl_triangle_vertex_indices &t = patch.indices[j]; const Vec3f &pa = patch.vertices[size_t(t[0])]; const Vec3f &pb = patch.vertices[size_t(t[1])]; const Vec3f &pc = patch.vertices[size_t(t[2])]; const float area2 = (pb - pa).cross(pc - pa).norm(); if (area2 <= 0.f) continue; const Vec3f centroid = (pa + pb + pc) / 3.f; // The normal the triplanar blend weights by, and the unwrap coordinate the LSCM // path needs, both averaged over the sub-triangle's corners - the same quantities // the per-vertex height sampling uses, evaluated at the centroid instead. Vec3f n = Vec3f::Zero(); Vec2f uv = Vec2f::Zero(); bool have_uv = !lscm_uvs.empty(); for (int k = 0; k < 3; ++k) { const int vi = t[k]; if (vi < int(vertex_normals.size())) n += vertex_normals[size_t(vi)]; if (!have_uv) continue; if (corner_uv_ok) uv += lscm_corner_uvs[j * 3 + size_t(k)]; else if (size_t(vi) < lscm_uvs.size()) uv += lscm_uvs[size_t(vi)]; else have_uv = false; } n = (n.norm() > 1e-8f) ? Vec3f(n.normalized()) : average_normal; uv /= 3.f; Vec3f rgb; if (sample_layer_color(tex, *layer, centroid, n, rgb, patch_centroid, patch_axis, have_uv ? &uv : nullptr)) { sum[S] += area2 * rgb; sum_area[S] += area2; } } for (size_t i = 0; i < mesh.indices.size(); ++i) if (sum_area[i] > 0.f) { const int idx = layer_quantize(sum[i] / sum_area[i]); // A quantizer that declines this colour leaves whatever a lower layer put // there, rather than punching a hole in it. if (idx >= 0) triangle_palette[i] = idx; } } } // Edge smoothing: a per-vertex weight in [0, 1] that fades the displacement to zero toward the // patch boundary. amount->0 leaves only the very edge softened; amount->1 fades the whole patch // flat. k = (1-a)/a turns the normalized boundary distance into that weight (see the header). std::vector edge_weight; if (layer->edge_smoothing && layer->edge_smoothing_amount > 0.f) { const std::vector bdist = patch_boundary_distance(patch, is_boundary); float max_d = 0.f; for (const float d : bdist) if (std::isfinite(d)) max_d = std::max(max_d, d); edge_weight.assign(patch.vertices.size(), 1.f); if (max_d > 1e-6f) { const float a = std::clamp(layer->edge_smoothing_amount, 0.f, 1.f); const float k = (a < 1.f) ? (1.f - a) / std::max(a, 1e-3f) : 0.f; for (size_t v = 0; v < bdist.size() && v < edge_weight.size(); ++v) { const float d = std::isfinite(bdist[v]) ? bdist[v] : max_d; edge_weight[v] = std::clamp((d / max_d) * k, 0.f, 1.f); } } } const float sign = layer->invert ? -1.f : 1.f; // A vertex may be reached by several of the patch's triangles; each must fold into the // running total exactly once, or a Multiply/Subtract layer would apply two or three times // over depending on how many painted triangles happen to share the vertex. Collecting the // unique list up front (cheap, one pass) is also what lets the expensive part - the texture // sampling, which is three bilinear fetches plus three pow()s per vertex for triplanar - run // in parallel below, instead of serially inside the triangle walk. std::vector layer_vertices; std::vector visited(patch.vertices.size(), 0); layer_vertices.reserve(patch.vertices.size()); for (const stl_triangle_vertex_indices &tri : patch.indices) for (int i = 0; i < 3; ++i) { const int vi = tri[i]; // Split vertices the brush introduced live past the end of our own vertex array; // they carry no displacement of their own and are not part of the output mesh. if (vi >= int(mesh.vertices.size()) || (pin_boundary && is_boundary[vi]) || visited[vi]) continue; visited[vi] = 1; layer_vertices.push_back(vi); } if (layer_vertices.empty()) continue; std::vector sampled(layer_vertices.size(), 0.f); tbb::parallel_for(tbb::blocked_range(0, layer_vertices.size()), [&](const tbb::blocked_range &range) { for (size_t k = range.begin(); k < range.end(); ++k) { const size_t vi = size_t(layer_vertices[k]); const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi]; const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi], patch_centroid, patch_axis, lscm_uv); // midlevel is the height that means "stay put", so anything below it displaces // *inwards* - see TextureDisplacementLayer::midlevel. At the default of 0 this is // exactly the old outward-only behaviour. sampled[k] = (h - layer->midlevel) * layer->depth_mm * sign; } }); for (size_t k = 0; k < layer_vertices.size(); ++k) { const size_t vi = size_t(layer_vertices[k]); // The first layer to reach a vertex has nothing underneath it to blend with, so it // always starts the total off additively - a Multiply/Divide against an implicit // zero base would otherwise annihilate (or blow up) it, which is never what the // user means by putting a mask on the bottom of the stack. const float accumulated = displacement[vi]; const float blended = blend_displacement(accumulated, sampled[k], displaced[vi] ? layer->blend_mode : TextureBlendMode::Add); // Edge smoothing fades this layer's *effect*, not its input. Scaling the input instead is // only correct for Add/Subtract, whose neutral value is 0: on a Multiply layer a faded // input approaches 0, which annihilates everything beneath it at the rim rather than // leaving it alone, and on a Divide layer it approaches the 0.05 divisor floor, which // amplifies the relief underneath by up to 20x exactly where it was meant to fade out. // Interpolating the blended result back toward the accumulated total is the neutral // element for every mode at once, and reduces to the old formula exactly for Add. const float edge_w = edge_weight.empty() ? 1.f : edge_weight[vi]; displacement[vi] = accumulated + (blended - accumulated) * edge_w; displaced[vi] = 1; } any_displacement = true; } if (!report(65)) return {}; if (!any_displacement) return mesh; // The model's own resting plane, taken before anything moves - see the clamp after smoothing. float resting_z = std::numeric_limits::max(); for (const Vec3f &v : mesh.vertices) resting_z = std::min(resting_z, v.z()); size_t moved_count = 0; for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) if (displaced[vi]) { mesh.vertices[vi] += vertex_normals[vi] * displacement[vi]; ++moved_count; } capture("displace", mesh, std::to_string(moved_count) + " of " + std::to_string(mesh.vertices.size()) + " vertices moved"); // Post-process relaxation of what the height maps left behind, restricted to the vertices that // actually moved - the untouched part of the model keeps its exact geometry, and the ring of // vertices just outside the displaced set stays put and anchors the smoothing so the relief does // not creep outward. `smooth_skip_border` additionally holds the patch's own outermost ring, whose // neighbours are those pinned outsiders: relaxing it would drag the rim of the relief back down and // leave the pattern looking half-melted right where it meets the edge. if (options.smooth_enabled && options.smooth_strength > 0.f && options.smooth_iterations > 0) { if (!report(70)) return {}; std::vector movable(mesh.vertices.size(), 0); for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) movable[vi] = (displaced[vi] && !(options.smooth_skip_border && on_patch_border[vi])) ? 1 : 0; // The pass hook only *stops* the relaxation early; the report(99) below is what turns a // cancellation into an empty (uncommittable) result, since a cancelled run keeps reporting // cancelled. smooth_mesh_vertices(mesh, movable, options.smooth_strength, options.smooth_iterations, progress ? DisplacementProgressFn([&report, it = options.smooth_iterations](int pass) { return report(70 + (29 * (pass + 1)) / std::max(it, 1)); }) : DisplacementProgressFn{}); capture("smooth", mesh, std::to_string(options.smooth_iterations) + " iterations at strength " + std::to_string(options.smooth_strength)); } // Nothing driven below the model's own resting plane can be printed: it is either through the // build plate or, once the slicer drops the part back down onto it, holding the whole model up in // the air. Push it back up to the plane. Only vertices the displacement actually moved are // eligible - untouched geometry is already exactly where it started - and only those that ended // up below it, so downward relief that stays clear of the plate is left alone. Runs in world // space (see build_texture_displacement()), so this really is the plate and not some scaled // stand-in for it. size_t clamped_count = 0; if (resting_z < std::numeric_limits::max()) for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) if (displaced[vi] && mesh.vertices[vi].z() < resting_z) { mesh.vertices[vi].z() = resting_z; ++clamped_count; } capture("clamp to plate", mesh, std::to_string(clamped_count) + " vertices raised to z = " + std::to_string(resting_z)); if (!report(99)) return {}; if (want_color) { // Despeckle in perceived-colour space, then resolve each entry to a real filament. The order // matters both ways round: filtering after the interleave would erase the bands it is supposed // to keep, and interleaving before the filter would have the filter treat two halves of one // blended colour as a disagreement. despeckle_triangle_colors(mesh, triangle_palette, color->despeckle_passes); merge_small_color_regions(mesh, triangle_palette, color->min_color_region_mm2); std::vector out_color(mesh.indices.size(), 0); for (size_t i = 0; i < mesh.indices.size(); ++i) { if (triangle_palette[i] < 0) continue; const stl_triangle_vertex_indices &t = mesh.indices[i]; const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &c = mesh.vertices[size_t(t[2])]; const Vec3f centroid = (a + b + c) / 3.f; Vec3f normal = (b - a).cross(c - a); const float nl = normal.norm(); normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid, normal) : triangle_palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); } // Handed over only on a run that completed: every early return above is a cancellation, and // the caller must not commit a half-computed colouring any more than a half-displaced mesh. *color->out_triangle = std::move(out_color); } return mesh; } indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, const TextureDisplacementOptions &options, const DisplacementProgressFn &progress, const TextureColorRequest *color, const Transform3d &volume_to_world, BakeStageRecorder *debug, TextureBakeStats *stats) { // An untransformed volume on an untransformed instance is by far the common case, and the round // trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take // the identity out of the way rather than paying for it. // The texture is projected in the bake frame - world orientation and scale, anchored at the // volume's origin (see texture_displacement_bake_frame()). const Transform3d frame = texture_displacement_bake_frame(volume_to_world); if (frame.matrix().isApprox(Transform3d::Identity().matrix())) return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color, false, debug, stats); const Transform3d to_local = frame.inverse(); // A mirroring placement leaves the positions correct but every winding-derived normal pointing // the wrong way. The winding itself is deliberately *not* touched here: the paint masks encode // each split triangle against its own vertex order, so reordering a triangle's vertices would // mirror the paint inside it. The bake is told instead, and negates the normals it computes. const bool mirrored = frame.linear().determinant() < 0.0; indexed_triangle_set world = base_mesh; for (Vec3f &v : world.vertices) v = (frame * v.cast()).cast(); const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0; indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options, progress, color, mirrored, debug, stats); // Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in // the volume's local frame, so the stages are brought back the same way the result is. if (debug != nullptr) debug->rebase(debug_mark, &to_local, /* flip_winding */ false); // A cancelled run returns {} and must stay {} - an empty mesh is the signal the caller checks // before committing anything onto the volume. if (out.vertices.empty()) return out; for (Vec3f &v : out.vertices) v = (to_local * v.cast()).cast(); return out; } void texture_displacement_patch_frame(const indexed_triangle_set &patch, const std::vector &vertex_normals, Vec3f ¢er, Vec3f &axis, Vec3f &average_normal) { Vec3f normal_sum = Vec3f::Zero(); Vec3f centroid_sum = Vec3f::Zero(); int count = 0; for (const stl_triangle_vertex_indices &tri : patch.indices) for (int i = 0; i < 3; ++i) { const int vi = tri[i]; centroid_sum += patch.vertices[size_t(vi)]; ++count; // A brush stroke that split a triangle appends new vertices past the base mesh's own, and // vertex_normals is sized to the base mesh, so those indices must be skipped here. if (vi < int(vertex_normals.size())) normal_sum += vertex_normals[size_t(vi)]; } average_normal = (normal_sum.norm() > 1e-8f) ? Vec3f(normal_sum.normalized()) : Vec3f::UnitZ(); center = (count > 0) ? Vec3f(centroid_sum / float(count)) : Vec3f::Zero(); // The world axis least aligned with the average normal - perpendicular to the outward radial // normal, as a cylinder's own axis would be. const Vec3f an = average_normal.cwiseAbs(); axis = (an.x() <= an.y() && an.x() <= an.z()) ? Vec3f::UnitX() : (an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ(); } Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world) { // World orientation and scale, but the origin moved to where the volume's own origin sits: the // texture then rides with the model when it is moved about the plate (and a single, untiled stamp // starts on the model rather than at the plate's corner), while a tile is still `tiling_scale` // printed millimetres whatever the instance's scale. return Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world; } Transform3d texture_displacement_volume_to_world(const ModelVolume &volume) { const ModelObject *object = volume.get_object(); if (object == nullptr || object->instances.empty() || object->instances.front() == nullptr) return volume.get_matrix(); return object->instances.front()->get_matrix() * volume.get_matrix(); } indexed_triangle_set build_texture_displacement(const ModelVolume &volume) { TextureDisplacementFacetsData facets_data; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data, volume.texture_displacement_options, {}, nullptr, texture_displacement_volume_to_world(volume)); } void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, int iterations, const DisplacementProgressFn &on_pass) { if (iterations <= 0 || mesh.vertices.empty() || movable.size() != mesh.vertices.size()) return; strength = std::clamp(strength, 0.f, 1.f); if (strength <= 0.f) return; if (std::none_of(movable.begin(), movable.end(), [](uint8_t m) { return m != 0; })) return; // One-ring neighbours as a CSR-style pair of arrays: counted, prefix-summed, then filled. A // triangle contributes each of its edges to both endpoints, so a shared edge is listed once per // incident triangle - the duplicates are harmless here, they just weight an interior edge the same // way from both sides, and dropping them would cost a sort per vertex for no visible difference. const size_t nv = mesh.vertices.size(); std::vector start(nv + 1, 0); for (const stl_triangle_vertex_indices &t : mesh.indices) for (int e = 0; e < 3; ++e) { ++start[size_t(t[e]) + 1]; ++start[size_t(t[(e + 1) % 3]) + 1]; } for (size_t v = 0; v < nv; ++v) start[v + 1] += start[v]; const size_t total_refs = size_t(start[nv]); std::vector nbr(total_refs, 0); std::vector fill(start.begin(), start.begin() + nv); for (const stl_triangle_vertex_indices &t : mesh.indices) for (int e = 0; e < 3; ++e) { const int a = t[e], b = t[(e + 1) % 3]; nbr[size_t(fill[size_t(a)]++)] = b; nbr[size_t(fill[size_t(b)]++)] = a; } // Read every pass from a snapshot of the previous one, so the result does not depend on the order // vertices happen to be visited in (a Gauss-Seidel sweep would smooth several times as hard at the // end of the array as at the start). std::vector prev; for (int it = 0; it < iterations; ++it) { prev = mesh.vertices; // Each vertex reads only from `prev` and writes only its own slot, so the sweep parallelises // with no synchronisation at all. tbb::parallel_for(tbb::blocked_range(0, nv), [&](const tbb::blocked_range &range) { for (size_t v = range.begin(); v < range.end(); ++v) { if (!movable[v] || start[v] == start[v + 1]) continue; Vec3f sum = Vec3f::Zero(); for (int k = start[v]; k < start[v + 1]; ++k) sum += prev[size_t(nbr[size_t(k)])]; const Vec3f avg = sum / float(start[v + 1] - start[v]); mesh.vertices[v] = prev[v] + (avg - prev[v]) * strength; } }); if (on_pass && !on_pass(it)) return; // cancelled: leave the passes done so far in place, the caller decides what to do } } namespace { // One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr so // the returned closure owns it for as long as the subdivider keeps calling back. // An unwrap turned into something a *point* sampler can use. LSCM has no formula from position to // uv - it is a per-triangle map - so a point is placed on the painted patch (the nearest patch // triangle, and its barycentric coordinates there) and the uv is interpolated from that triangle's own // per-corner uvs. Exact for a point on the base surface, which is where both samplers are queried: the // displacement samples refined positions before moving them, and the colour pass samples the foot // point on the painted pieces. struct LscmLookup { indexed_triangle_set patch; // the layer's painted patch, as the unwrap was solved on AABBTreeIndirect::Tree3f tree; std::vector corner; // compute_lscm_corner_uvs(patch, layer) // False when `pos` is not on this layer's patch (farther than `tol`): there is no uv there, so the // layer contributes nothing - the same as a non-tiled texture outside its placement. bool uv_at(const Vec3f &pos, float tol, Vec2f &uv) const { size_t hit = 0; Vec3f foot; const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(patch.vertices, patch.indices, tree, pos, hit, foot); if (d2 < 0.f || d2 > tol * tol || hit >= patch.indices.size()) return false; const stl_triangle_vertex_indices &t = patch.indices[hit]; const Vec3f &a = patch.vertices[size_t(t[0])], &b = patch.vertices[size_t(t[1])], &c = patch.vertices[size_t(t[2])]; const Vec3f e0 = b - a, e1 = c - a, ep = foot - a; const float d00 = e0.dot(e0), d01 = e0.dot(e1), d11 = e1.dot(e1), dp0 = ep.dot(e0), dp1 = ep.dot(e1); const float den = d00 * d11 - d01 * d01; float w1 = 1.f / 3.f, w2 = 1.f / 3.f; // a degenerate triangle takes its centroid's uv if (std::abs(den) > 1e-20f) { w1 = (d11 * dp0 - d01 * dp1) / den; w2 = (d00 * dp1 - d01 * dp0) / den; } const Vec2f *c3 = &corner[hit * 3]; uv = (1.f - w1 - w2) * c3[0] + w1 * c3[1] + w2 * c3[2]; return true; } }; // A layer's painted patch as a point-in-region test. Every layer is sampled on its own paint only - the // analytic projections included: unlike an unwrap they are defined everywhere, so without this every layer's // relief was stacked over every other layer's painted area, and the top layer's texture showed on all of them. struct PatchRegion { indexed_triangle_set patch; AABBTreeIndirect::Tree3f tree; bool contains(const Vec3f &pos, float tol) const { size_t hit = 0; Vec3f foot; const float d2 = AABBTreeIndirect::squared_distance_to_indexed_triangle_set(patch.vertices, patch.indices, tree, pos, hit, foot); return d2 >= 0.f && d2 <= tol * tol; } }; struct PreparedLayer { DecodedHeightTexture tex; TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...) Vec3f center; // patch centroid, for Cylindrical/Spherical Vec3f axis; // cylinder axis, for Cylindrical // Unwrap layers only; null when the unwrap failed, in which case sampling falls through to the // layer's analytic fallback exactly as build_texture_displacement()'s classic path does. std::shared_ptr lscm; // The painted patch of a layer without an unwrap lookup (the unwrap's own lookup already stops at its patch). // Null when the paint covers the whole mesh, where every point is on it. std::shared_ptr region; // The uv to hand sample_layer_height()/sample_layer_color(): nullptr for every analytic projection // (they project `pos` themselves). False means `pos` is off this layer's paint and the layer must be // skipped. bool lscm_uv(const Vec3f &pos, Vec2f &uv, const Vec2f *&out) const { out = nullptr; // Queries lie on the base surface, so anything beyond a hair is off this layer's patch. constexpr float ON_PATCH_TOL = 0.05f; if (region && !region->contains(pos, ON_PATCH_TOL)) return false; if (!lscm) return true; if (!lscm->uv_at(pos, ON_PATCH_TOL, uv)) return false; out = &uv; return true; } }; // Shared by both point samplers, so the height field and the colour field can never disagree about // where a layer is placed. `need_color` additionally drops layers that cannot contribute colour. std::shared_ptr> prepare_sampleable_layers( const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, bool need_color) { auto prepared = std::make_shared>(); if (base_mesh.indices.empty()) return prepared; std::vector ordered; for (const TextureDisplacementLayer &l : layers) if (l.slot >= 0 && l.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) ordered.push_back(&l); std::sort(ordered.begin(), ordered.end(), [](const TextureDisplacementLayer *a, const TextureDisplacementLayer *b) { return a->slot < b->slot; }); const std::vector vertex_normals = texture_displacement_vertex_normals(base_mesh); const TriangleMesh selector_mesh(base_mesh); const float mesh_area = area_3d(base_mesh); for (const TextureDisplacementLayer *layer : ordered) { // Unwrap layers used to be skipped here ("no per-point UV"). That made the default pipeline // bake an unwrap layer as nothing at all - and since the job then clears the baked layers' // paint, the painted region simply vanished. They get an LscmLookup below instead. if (need_color && !layer->color_enabled) continue; const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)]; if (data.triangles_to_split.empty()) continue; const DecodedHeightTexture tex = decode_height_texture(*layer); if (tex.empty() || (need_color && !tex.has_color())) continue; TriangleSelector selector(selector_mesh); selector.deserialize(data, false); const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER); if (patch.indices.empty()) continue; // Patch centroid + cylinder axis, shared with build_texture_displacement() so a // Cylindrical/Spherical layer's detach criterion matches the geometry the bake will produce. Vec3f centroid, axis, average_normal; texture_displacement_patch_frame(patch, vertex_normals, centroid, axis, average_normal); std::shared_ptr lscm; if (layer->projection_method == TextureProjectionMethod::LSCM) { // Solved on the very patch the classic path and the GUI solve it on (same geometry, seam // angle and edges), so it hits the unwrap cache and lands exactly where the UV editor // shows it, hand-placed islands and UV edits included. auto l = std::make_shared(); l->corner = compute_lscm_corner_uvs(patch, *layer); if (l->corner.size() == patch.indices.size() * 3) { l->patch = patch; l->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(l->patch.vertices, l->patch.indices); lscm = std::move(l); } } std::shared_ptr region; if (!lscm && area_3d(patch) < 0.9999f * mesh_area) { auto r = std::make_shared(); r->patch = patch; r->tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(r->patch.vertices, r->patch.indices); region = std::move(r); } prepared->push_back({ tex, *layer, centroid, axis, std::move(lscm), std::move(region) }); } return prepared; } } // namespace ColorFieldSampler make_combined_color_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, ColorQuantizeFn quantize, ColorQuantizeFn quantize_pure) { if (!quantize) return nullptr; auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ true); if (prepared->empty()) return nullptr; // Per layer: a flat-colour image is matched against the filaments alone, when that quantizer // was supplied; anything else may use the mixes. Decided once here, not per sample. auto pure = std::make_shared>(prepared->size(), 0); if (quantize_pure) for (size_t i = 0; i < prepared->size(); ++i) (*pure)[i] = analyze_texture_detail((*prepared)[i].layer).flat_colors ? 1 : 0; return [prepared, pure, quantize = std::move(quantize), quantize_pure = std::move(quantize_pure)](const Vec3f &pos, const Vec3f &normal) -> int { // Last one wins: `prepared` is in ascending slot order and the bake lets a higher layer // overwrite a lower one's colour, so the sampler has to resolve overlaps the same way. int result = -1; for (size_t i = 0; i < prepared->size(); ++i) { const PreparedLayer &p = (*prepared)[i]; Vec2f uv; const Vec2f *lscm_uv = nullptr; if (!p.lscm_uv(pos, uv, lscm_uv)) continue; Vec3f rgb; if (sample_layer_color(p.tex, p.layer, pos, normal, rgb, p.center, p.axis, lscm_uv)) if (const int idx = ((*pure)[i] ? quantize_pure : quantize)(rgb); idx >= 0) result = idx; } return result; }; } HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data) { auto prepared = prepare_sampleable_layers(base_mesh, layers, facets_data, /* need_color */ false); if (prepared->empty()) return nullptr; return [prepared](const Vec3f &pos, const Vec3f &normal) -> float { float total = 0.f; bool any = false; for (const PreparedLayer &p : *prepared) { Vec2f uv; const Vec2f *lscm_uv = nullptr; if (!p.lscm_uv(pos, uv, lscm_uv)) continue; // off this unwrap layer's patch: no uv, so no contribution const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, lscm_uv); const float sign = p.layer.invert ? -1.f : 1.f; const float signed_h = (h - p.layer.midlevel) * p.layer.depth_mm * sign; // The first (lowest) sampleable layer folds additively; the rest use their own blend mode - // same rule build_texture_displacement() applies per vertex. total = blend_displacement(total, signed_h, any ? p.layer.blend_mode : TextureBlendMode::Add); any = true; } return total; }; } indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, float max_edge_length_mm, int max_iterations) { indexed_triangle_set current = mesh; const float max_edge_sq = max_edge_length_mm * max_edge_length_mm; for (int iter = 0; iter < max_iterations; ++iter) { // One vertex-index pair (always stored low-index-first) -> the midpoint vertex already // created for it in this pass, so the two triangles sharing that edge both get the exact // same new vertex instead of two separate, coincident-but-distinct ones (which would leave // the mesh non-manifold even though it looks fine). std::unordered_map midpoint_cache; auto edge_key = [](int a, int b) -> uint64_t { if (a > b) std::swap(a, b); return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); }; auto get_midpoint = [&](int a, int b) -> int { const uint64_t key = edge_key(a, b); auto it = midpoint_cache.find(key); if (it != midpoint_cache.end()) return it->second; const int idx = int(current.vertices.size()); current.vertices.push_back((current.vertices[a] + current.vertices[b]) * 0.5f); midpoint_cache.emplace(key, idx); return idx; }; std::vector new_indices; new_indices.reserve(current.indices.size()); bool any_split = false; for (const stl_triangle_vertex_indices &tri : current.indices) { const Vec3f &a = current.vertices[tri[0]]; const Vec3f &b = current.vertices[tri[1]]; const Vec3f &c = current.vertices[tri[2]]; if ((b - a).squaredNorm() <= max_edge_sq && (c - b).squaredNorm() <= max_edge_sq && (a - c).squaredNorm() <= max_edge_sq) { new_indices.push_back(tri); continue; } any_split = true; const int m01 = get_midpoint(tri[0], tri[1]); const int m12 = get_midpoint(tri[1], tri[2]); const int m20 = get_midpoint(tri[2], tri[0]); new_indices.push_back(stl_triangle_vertex_indices(tri[0], m01, m20)); new_indices.push_back(stl_triangle_vertex_indices(m01, tri[1], m12)); new_indices.push_back(stl_triangle_vertex_indices(m20, m12, tri[2])); new_indices.push_back(stl_triangle_vertex_indices(m01, m12, m20)); } current.indices = std::move(new_indices); if (!any_split) break; } return current; } indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, const std::vector &refine_region, float target_edge_length_mm, int max_triangles, std::vector *out_source, const HeightFieldSampler &sampler, float chord_tolerance_mm, float min_edge_length_mm, float border_edge_length_mm, const DisplacementProgressFn &progress, const ColorFieldSampler &color, float color_edge_length_mm, bool split_multi_crossings) { // Neighbour slots that are not a triangle index. constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone constexpr int NB_NONMANIFOLD = -2; // >2 triangles on the edge: never bisected, that would tear it // v[] and nb[] are parallel: nb[e] is the triangle across edge (v[e], v[(e+1)%3]). `src` is the // input triangle this one descends from - children inherit it, so a caller can carry per-triangle // data (a paint mask) across the topology change with no geometric remap. struct Tri { int v[3]; int nb[3]; int src; }; std::vector verts = mesh.vertices; std::vector tris(mesh.indices.size()); for (size_t i = 0; i < mesh.indices.size(); ++i) tris[i] = { { mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2] }, { NB_BOUNDARY, NB_BOUNDARY, NB_BOUNDARY }, int(i) }; auto emit = [&]() -> indexed_triangle_set { indexed_triangle_set out; out.vertices = verts; out.indices.reserve(tris.size()); if (out_source) { out_source->clear(); out_source->reserve(tris.size()); } for (const Tri &t : tris) { out.indices.emplace_back(t.v[0], t.v[1], t.v[2]); if (out_source) out_source->push_back(t.src); } return out; }; // Feature-adaptive when a sampler and a positive tolerance are supplied; otherwise refinement is // driven by the length baseline alone. const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f; const bool color_mode = bool(color) && color_edge_length_mm > 0.f; const bool multi_mode = feature_mode && split_multi_crossings; float step_iso_mm = 0.f; // the mid-level a step crosses; set once the region is sampled float step_dev_mm = std::numeric_limits::max(); // deviations above this are steps const float color_sq = color_edge_length_mm > 0.f ? color_edge_length_mm * color_edge_length_mm : 0.f; const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f; const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f; const float border_sq = border_edge_length_mm > 0.f ? border_edge_length_mm * border_edge_length_mm : 0.f; // refine_region is indexed by input-triangle index, and every triangle's src stays in that range // (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it. if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles) return emit(); if (!feature_mode && !color_mode && target_sq <= 0.f && border_sq <= 0.f) return emit(); // no criterion at all if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; })) return emit(); // nothing flagged: no-op auto edge_key = [](int a, int b) -> uint64_t { if (a > b) std::swap(a, b); return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); }; // Edge adjacency, built once here and then maintained incrementally by bisect() below. Rebuilding // it per refinement pass is what made the previous version's cost scale with the whole model // instead of with the refined region, and what forced the tiny pass budget that stopped // refinement short. { // Sort the half-edges by their edge key and walk the equal runs, rather than hashing every one // of them twice into an unordered_map. Same result, but the two expensive parts - forming the // keys and ordering them - both parallelise, where a shared hash map cannot. The map also cost // a second full pass of lookups purely to read back what the first pass had just inserted. std::vector> he(tris.size() * 3); // (edge key, triangle * 3 + local edge) tbb::parallel_for(tbb::blocked_range(0, tris.size()), [&](const tbb::blocked_range &range) { for (size_t ti = range.begin(); ti < range.end(); ++ti) for (int e = 0; e < 3; ++e) he[ti * 3 + size_t(e)] = { edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]), int(ti) * 3 + e }; }); tbb::parallel_sort(he.begin(), he.end()); // Runs of equal key are the half-edges of one edge: one is a boundary, two are neighbours, // more is non-manifold. Serial, but it is a single linear pass over an already ordered array. for (size_t i = 0; i < he.size();) { size_t j = i + 1; while (j < he.size() && he[j].first == he[i].first) ++j; const size_t count = j - i; if (count == 2) { const int a = he[i].second, b = he[i + 1].second; tris[size_t(a / 3)].nb[a % 3] = b / 3; tris[size_t(b / 3)].nb[b % 3] = a / 3; } else if (count > 2) { for (size_t k = i; k < j; ++k) tris[size_t(he[k].second / 3)].nb[he[k].second % 3] = NB_NONMANIFOLD; } // count == 1 keeps the NB_BOUNDARY it was initialised with. i = j; } } // Feature mode: per-vertex surface normal, and the sampled displacement height at each vertex. // Heights are filled in lazily - on a big model only a small painted region is ever looked at, and // a sampler call is a texture fetch (plus trig) per layer, so sampling every vertex of the whole // mesh up front was pure waste. Both arrays grow in lockstep with `verts`. std::vector vnormal; std::vector vheight; std::vector vheight_valid; if (feature_mode) { vnormal.assign(verts.size(), Vec3f::Zero()); for (const Tri &t : tris) { const Vec3f fn = (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]); // area-weighted for (int i = 0; i < 3; ++i) vnormal[t.v[i]] += fn; } for (Vec3f &n : vnormal) { const float l = n.norm(); n = (l > 1e-12f) ? Vec3f(n / l) : Vec3f(Vec3f::UnitZ()); } vheight.assign(verts.size(), 0.f); vheight_valid.assign(verts.size(), 0); } auto height_of = [&](int v) -> float { if (!vheight_valid[v]) { vheight[v] = sampler(verts[v], vnormal[v]); vheight_valid[v] = 1; } return vheight[v]; }; // Per-vertex filament index, sampled lazily and cached the same way the heights are. Needs the // vertex normals, which feature mode also builds - so colour mode builds them when it is on alone. std::vector vcolor; std::vector vcolor_valid; if (color_mode) { if (vnormal.empty()) { vnormal.assign(verts.size(), Vec3f::Zero()); for (const Tri &t : tris) { const Vec3f fn = (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]); for (int i = 0; i < 3; ++i) vnormal[t.v[i]] += fn; } for (Vec3f &n : vnormal) { const float l = n.norm(); n = (l > 1e-12f) ? Vec3f(n / l) : Vec3f(Vec3f::UnitZ()); } } vcolor.assign(verts.size(), -2); // -2 = not sampled yet; -1 = sampled, no colour there vcolor_valid.assign(verts.size(), 0); } auto color_of = [&](int v) -> int { if (!vcolor_valid[v]) { vcolor[v] = color(verts[v], vnormal[v]); vcolor_valid[v] = 1; } return vcolor[v]; }; // Sample the input mesh's own vertices up front, in parallel, for the region that is going to be // refined. A sampler call is a texture fetch plus the projection's trigonometry per layer, and it // is by far the most expensive thing here - but taken one at a time from inside the refinement // loop it is also strictly serial. Every one of these vertices is read by the very first scoring // pass anyway, so doing them together costs nothing extra and hands the work to every core. // // Only the region, and only the *initial* vertices: the laziness this replaces exists so that a // small painted patch on a big model does not pay for the whole model (see height_of()), and that // still holds. Midpoints created later stay lazy, because they do not exist yet. if (feature_mode || color_mode) { std::vector wanted(verts.size(), 0); for (const Tri &t : tris) if (refine_region[t.src] != 0) for (int i = 0; i < 3; ++i) wanted[size_t(t.v[i])] = 1; // Each index is touched by exactly one iteration, so the lazy caches can be filled without // synchronisation - and every value is the one height_of()/color_of() would have produced. tbb::parallel_for(tbb::blocked_range(0, verts.size()), [&](const tbb::blocked_range &range) { for (size_t v = range.begin(); v < range.end(); ++v) { if (!wanted[v]) continue; if (feature_mode) { vheight[v] = sampler(verts[v], vnormal[v]); vheight_valid[v] = 1; } if (color_mode) { vcolor[v] = color(verts[v], vnormal[v]); vcolor_valid[v] = 1; } } }); if (multi_mode) { // The level a step crosses: midway through the relief actually present on the region. float lo = std::numeric_limits::max(), hi = -lo; for (size_t v = 0; v < verts.size(); ++v) if (wanted[v]) { lo = std::min(lo, vheight[v]); hi = std::max(hi, vheight[v]); } step_iso_mm = (lo < hi) ? 0.5f * (lo + hi) : 0.f; step_dev_mm = (lo < hi) ? 0.4f * (hi - lo) : std::numeric_limits::max(); } } auto elen_sq = [&](int a, int b) -> float { return (verts[a] - verts[b]).squaredNorm(); }; // The one edge of a triangle taken as its "longest": greatest squared length, exact ties broken by // the smaller (sorted) vertex-index key. Both triangles sharing an edge compute the same key for // it, so they can never disagree about which of them is longest - the property the conformality // argument and the LEPP walk's termination both rest on. auto longest_local = [&](int ti) -> int { const Tri &t = tris[ti]; int best = 0; float bl = elen_sq(t.v[0], t.v[1]); uint64_t bk = edge_key(t.v[0], t.v[1]); for (int e = 1; e < 3; ++e) { const float l = elen_sq(t.v[e], t.v[(e + 1) % 3]); const uint64_t k = edge_key(t.v[e], t.v[(e + 1) % 3]); if (l > bl || (l == bl && k < bk)) { bl = l; bk = k; best = e; } } return best; }; // How far the *displaced* surface departs from the flat triangle, sampled across the WHOLE // triangle - the three edge midpoints and the centroid - not just one edge midpoint. Sampling the // interior is what catches a hill that sits inside a triangle (the blind spot of an edge-only // test). Cached per triangle: it can only change when the triangle is split, and then both // children are fresh entries. std::vector tri_err; if (feature_mode) tri_err.assign(tris.size(), -1.f); // True when this triangle straddles a colour boundary: its corners, its edge midpoints and its // centroid do not all take the same filament. The midpoints and centroid matter for the same // reason they do in detail_error() - a boundary can cross a triangle without separating any two of // its corners. Cached per triangle; a split invalidates both children. std::vector tri_color_split; // 0 = unknown, 1 = straddles, 2 = uniform if (color_mode) tri_color_split.assign(tris.size(), 0); auto straddles_color = [&](int ti) -> bool { if (tri_color_split[ti] != 0) return tri_color_split[ti] == 1; const Tri &t = tris[ti]; const Vec3f pa = verts[t.v[0]], pb = verts[t.v[1]], pc = verts[t.v[2]]; const Vec3f na = vnormal[t.v[0]], nb = vnormal[t.v[1]], nc = vnormal[t.v[2]]; const int ca = color_of(t.v[0]); bool split = color_of(t.v[1]) != ca || color_of(t.v[2]) != ca; if (!split) { static const float BARY[4][3] = { { 0.5f, 0.5f, 0.f }, { 0.f, 0.5f, 0.5f }, { 0.5f, 0.f, 0.5f }, { 1.f / 3, 1.f / 3, 1.f / 3 } }; for (const auto &w : BARY) { Vec3f n = w[0] * na + w[1] * nb + w[2] * nc; const float nl = n.norm(); n = (nl > 1e-12f) ? Vec3f(n / nl) : na; if (color(w[0] * pa + w[1] * pb + w[2] * pc, n) != ca) { split = true; break; } } } tri_color_split[ti] = split ? 1 : 2; return split; }; // The spacing at which the chord test samples a triangle. Features narrower than the resolution // floor cannot be resolved by refinement anyway, so sampling any finer than that only costs time; // sampling any coarser is what let thin features fall between the samples. Below the floor's own // scale it is held at 0.05 mm, since finer is invisible on an FDM part. const float sample_spacing = std::max(min_edge_length_mm, 0.05f); auto detail_error = [&](int ti) -> float { if (tri_err[ti] >= 0.f) return tri_err[ti]; const Tri &t = tris[ti]; const Vec3f pa = verts[t.v[0]], pb = verts[t.v[1]], pc = verts[t.v[2]]; const Vec3f na = vnormal[t.v[0]], nb = vnormal[t.v[1]], nc = vnormal[t.v[2]]; const float ha = height_of(t.v[0]), hb = height_of(t.v[1]), hc = height_of(t.v[2]); // A barycentric lattice over the whole triangle, dense enough that no feature wider than the // sample spacing can hide between two samples. Four fixed samples (the three edge midpoints // and the centroid) were the previous test, and on a 1 mm starting triangle they left 0.5 mm // gaps - wider than a grid line or a knurl ridge - so whole features were never seen, the // triangle scored zero, and it was never refined. The lattice is bounded at 8 subdivisions // (45 points) so a large triangle does not cost thousands of samples; anything it misses at // that size is caught once its children are small enough for the lattice to reach it. const float longest = std::sqrt(std::max({ (pb - pa).squaredNorm(), (pc - pb).squaredNorm(), (pa - pc).squaredNorm() })); const int n = std::clamp(int(std::ceil(longest / sample_spacing)), 2, 8); float maxerr = 0.f; // Whether the mid-level contour crosses an edge with a jump that is a step's worth, walked at // the sample spacing (the lattice below is too coarse on a long edge: a 1 mm edge gets 8 // samples, and a 0.15 mm grid line slips between them). Such a triangle is the cutter's. bool sharp_cross = false; if (multi_mode) { const Vec3f *P[3] = { &pa, &pb, &pc }; const Vec3f *Nn[3] = { &na, &nb, &nc }; const float H[3] = { ha, hb, hc }; for (int e = 0; e < 3; ++e) { const Vec3f &p0 = *P[e], &p1 = *P[(e + 1) % 3], &n0 = *Nn[e], &n1 = *Nn[(e + 1) % 3]; const int m = std::clamp(int(std::ceil((p1 - p0).norm() / sample_spacing)), 2, 32); float last = H[e]; for (int i = 1; i <= m; ++i) { float h; if (i == m) h = H[(e + 1) % 3]; else { const float t = float(i) / float(m); Vec3f nn = n0 + (n1 - n0) * t; const float nl = nn.norm(); nn = (nl > 1e-12f) ? Vec3f(nn / nl) : n0; h = sampler(p0 + (p1 - p0) * t, nn); } if ((h > step_iso_mm) != (last > step_iso_mm) && std::abs(h - last) > step_dev_mm) { sharp_cross = true; break; } last = h; } if (sharp_cross) break; } } for (int i = 0; i <= n; ++i) for (int j = 0; j <= n - i; ++j) { const int k = n - i - j; const float wa = float(i) / float(n), wb = float(j) / float(n), wc = float(k) / float(n); float actual; if (i == n) actual = ha; else if (j == n) actual = hb; else if (k == n) actual = hc; else { Vec3f nn = wa * na + wb * nb + wc * nc; const float nl = nn.norm(); nn = (nl > 1e-12f) ? Vec3f(nn / nl) : na; actual = sampler(wa * pa + wb * pb + wc * pc, nn); // Corners lie on the plane by construction; only the rest carry chord error. maxerr = std::max(maxerr, std::abs(actual - (wa * ha + wb * hb + wc * hc))); } } // A sharp step crossing the triangle is the cutter's job. Refinement cannot bring such a // triangle under tolerance - a discontinuity has no chord - and would only carpet the step // down to the floor, so its chord error is not counted. A feature lying entirely inside the // triangle crosses no edge and still counts, which is what makes it surface until it does // cross one. if (multi_mode && sharp_cross) maxerr = 0.f; tri_err[ti] = maxerr; return maxerr; }; // How many times over its criteria a triangle is: <= 1 means "good enough, leave it alone", and // the larger the value the more a split buys. Driving the heap with this is what makes a run that // runs out of budget spend it on the worst offenders instead of wherever a sweep happened to // reach. Triangles outside the region always score 0 - they are only ever touched by the conformal // closure below, never refined on their own account. auto priority = [&](int ti) -> float { const Tri &t = tris[ti]; const uint8_t flags = refine_region[t.src]; if (flags == 0) return 0.f; const int le = longest_local(ti); const float ll = elen_sq(t.v[le], t.v[(le + 1) % 3]); if (ll <= min_floor_sq) return 0.f; // at the resolution floor - also what stops a sharp texture step going forever float p = 0.f; if (flags & REFINE_PAINTED) { p = (target_sq > 0.f) ? ll / target_sq : 0.f; if (feature_mode) { p = std::max(p, detail_error(ti) / chord_tolerance_mm); } // Colour is per facet, so a colour boundary can only be drawn where there are edges along // it. Length target, floored by min_edge_length_mm above, exactly like the border band. if (color_mode && straddles_color(ti)) p = std::max(p, ll / color_sq); } // The band straddling the paint's edge, refined by plain edge length. Deliberately *not* run // through detail_error(): the sampler reports no relief off the paint, so across its edge the // chord test sees a step and would chase it down to the length floor. Length alone is what this // band needs - the error it is fixing is the size of the triangles spanning the displacement // step, not the curvature of anything. if ((flags & REFINE_BORDER) && border_sq > 0.f) p = std::max(p, ll / border_sq); return p; }; // What ordering the heap by. priority() alone saturates: a triangle straddling a hard step in the // texture scores relief / tolerance however small it gets, so its children always come back to the // top and the whole budget is spent carpeting step edges down to the floor while a large triangle // with a moderate error a few millimetres away is never reached. Weighting by area makes the key // the surface error a split actually removes, which is what a fixed budget should be spent on - // and once the budget is gone, what remains is spread evenly instead of piled on one feature. auto tri_area = [&](int ti) -> float { const Tri &t = tris[ti]; return 0.5f * (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]).norm(); }; auto heap_key = [&](int ti, float p) -> float { return p * tri_area(ti); }; auto set_nb = [&](int ti, int u, int v, int val) { if (ti < 0) return; Tri &t = tris[ti]; for (int e = 0; e < 3; ++e) if ((t.v[e] == u && t.v[(e + 1) % 3] == v) || (t.v[e] == v && t.v[(e + 1) % 3] == u)) { t.nb[e] = val; return; } }; // Bisects triangle `ti` across its local edge `e`, which the caller has established is terminal. // Both triangles on that edge are split in the one operation, around a single shared midpoint - // which is exactly why a hanging node (and so a crack) can never appear. `ti` and the opposite // triangle are each reused as one of their own children, so only two back-pointers in the // surrounding mesh need repointing. Triangles whose geometry changed are left in `touched`. std::vector touched; auto bisect = [&](int ti, int e) -> bool { const int a = tris[ti].v[e], b = tris[ti].v[(e + 1) % 3], c = tris[ti].v[(e + 2) % 3]; const int n = tris[ti].nb[e]; // Locate the shared edge from the far side before touching anything: bailing out half way // through would be the one way this could leave a crack. int f = -1; if (n >= 0) { for (int k = 0; k < 3; ++k) { const int u = tris[n].v[k], w = tris[n].v[(k + 1) % 3]; if ((u == a && w == b) || (u == b && w == a)) { f = k; break; } } if (f < 0) { tris[ti].nb[e] = NB_NONMANIFOLD; // inconsistent adjacency: refuse to split across it return false; } } const int m = int(verts.size()); verts.push_back(0.5f * (verts[a] + verts[b])); if (feature_mode || color_mode) { const Vec3f mn = vnormal[a] + vnormal[b]; const float ml = mn.norm(); vnormal.push_back(ml > 1e-12f ? Vec3f(mn / ml) : vnormal[a]); } if (feature_mode) { vheight.push_back(0.f); vheight_valid.push_back(0); } if (color_mode) { vcolor.push_back(-2); vcolor_valid.push_back(0); } // Near side: ti becomes (a, m, c), the new triangle is (m, b, c). Both keep the original // a->b->c winding. const int nb_bc = tris[ti].nb[(e + 1) % 3]; const int nb_ca = tris[ti].nb[(e + 2) % 3]; const int src = tris[ti].src; const int t2 = int(tris.size()); tris.push_back(Tri{ { m, b, c }, { NB_BOUNDARY, nb_bc, ti }, src }); { Tri &t1 = tris[ti]; t1.v[0] = a; t1.v[1] = m; t1.v[2] = c; t1.nb[0] = NB_BOUNDARY; t1.nb[1] = t2; t1.nb[2] = nb_ca; } set_nb(nb_bc, b, c, t2); // that outer neighbour borders the second child now, not ti if (feature_mode) { tri_err.push_back(-1.f); tri_err[ti] = -1.f; } if (color_mode) { tri_color_split.push_back(0); tri_color_split[ti] = 0; } touched.assign({ ti, t2 }); if (n < 0) { return true; // boundary edge: nothing on the far side to split } // Far side, same shape: n becomes (p, m, d), the new triangle is (m, q, d). const int p = tris[n].v[f], q = tris[n].v[(f + 1) % 3], d = tris[n].v[(f + 2) % 3]; const int nb_qd = tris[n].nb[(f + 1) % 3]; const int nb_dp = tris[n].nb[(f + 2) % 3]; const int nsrc = tris[n].src; const int n2 = int(tris.size()); tris.push_back(Tri{ { m, q, d }, { NB_BOUNDARY, nb_qd, n }, nsrc }); { Tri &n1 = tris[n]; n1.v[0] = p; n1.v[1] = m; n1.v[2] = d; n1.nb[0] = NB_BOUNDARY; n1.nb[1] = n2; n1.nb[2] = nb_dp; } set_nb(nb_qd, q, d, n2); if (feature_mode) { tri_err.push_back(-1.f); tri_err[n] = -1.f; } if (color_mode) { tri_color_split.push_back(0); tri_color_split[n] = 0; } // Stitch the two sides back together: whichever far child holds `a` borders the near child // that holds `a`. (Which one that is depends on how n happens to be wound.) const int side_a = (p == a) ? n : n2; const int side_b = (p == a) ? n2 : n; tris[ti].nb[0] = side_a; // near child (a, m, c), edge (a, m) tris[t2].nb[0] = side_b; // near child (m, b, c), edge (m, b) set_nb(side_a, a, m, ti); set_nb(side_b, b, m, t2); touched.assign({ ti, t2, n, n2 }); return true; }; // Worst-first. Entries go stale as their triangle is split; a stale entry is harmless - it is // re-scored on pop and dropped or re-pushed. The ordering is a budget-allocation heuristic only: // neither correctness nor conformality depends on it. std::priority_queue> queue; { // Scoring the starting mesh means a detail_error() per triangle - four more sampler calls each // - so it is worth spreading, even though the refinement that follows cannot be. Each entry is // written by one iteration only, and the caches those calls fill (tri_err, tri_color_split) are // likewise per triangle, so there is nothing shared to guard. The heap is then built from the // finished array in index order, which is exactly the order the serial loop pushed in. std::vector initial(tris.size(), 0.f); tbb::parallel_for(tbb::blocked_range(0, tris.size()), [&](const tbb::blocked_range &range) { for (size_t ti = range.begin(); ti < range.end(); ++ti) initial[ti] = priority(int(ti)); }); for (int ti = 0; ti < int(tris.size()); ++ti) if (initial[size_t(ti)] > 1.f) queue.emplace(heap_key(ti, initial[size_t(ti)]), ti); } // Every iteration either drops one satisfied triangle from the queue or performs exactly one // bisection, and bisections are capped by the triangle budget, so this always terminates. // Progress is reported against the triangle budget, which is what the loop is bounded by. Polled // rather than pushed on every bisection: a refinement spends its budget in hundreds of thousands // of them, and every hook call wakes the UI's idle loop to repaint the notification. const int start_tris = int(tris.size()); const int budget_tris = std::max(1, max_triangles - start_tris); int next_poll = start_tris; while (!queue.empty() && int(tris.size()) + 2 <= max_triangles) { if (progress && int(tris.size()) >= next_poll) { next_poll = int(tris.size()) + std::max(1024, budget_tris / 100); if (!progress(std::clamp((int(tris.size()) - start_tris) * 100 / budget_tris, 0, 100))) break; // still conformal - whole bisections only; the caller decides whether to keep it } const int ti = queue.top().second; queue.pop(); if (priority(ti) <= 1.f) continue; // stale: already refined past its criteria // Longest-Edge Propagation Path: step to the neighbour across the current longest edge for as // long as that neighbour has a strictly longer one, then bisect the terminal edge we land on. // Length strictly increases along the path, so it cannot cycle. int cur = ti, split_edge = -1; for (size_t guard = 0; guard <= tris.size(); ++guard) { const int le = longest_local(cur); const int nbr = tris[cur].nb[le]; if (nbr == NB_NONMANIFOLD) break; // cannot split across it without tearing the mesh: give up on this path if (nbr == NB_BOUNDARY) { split_edge = le; // boundary longest edge -> terminal break; } const int nle = longest_local(nbr); if (edge_key(tris[nbr].v[nle], tris[nbr].v[(nle + 1) % 3]) == edge_key(tris[cur].v[le], tris[cur].v[(le + 1) % 3])) { split_edge = le; // mutual longest edge -> terminal break; } cur = nbr; } if (split_edge < 0 || !bisect(cur, split_edge)) continue; // ti sits in a non-manifold neighbourhood and cannot be refined safely for (const int t : touched) if (const float p = priority(t); p > 1.f) queue.emplace(heap_key(t, p), t); // The bisection may have been a step on the way to ti rather than ti itself, in which case ti // is not in `touched` and has to go back on the heap to be walked again. if (cur != ti) if (const float p = priority(ti); p > 1.f) queue.emplace(heap_key(ti, p), ti); } return emit(); } namespace { // Ear clipping of a simple polygon given counter-clockwise in 2D; `pts` are indexed by `poly`. Small // polygons only (a triangle clipped by a few contour polylines), so the plain O(n^2) form is enough. // A vertex that lies on a candidate ear's boundary does not block it, and when no ear can be found // (numerically degenerate input) the flattest vertex is dropped rather than fanning blindly, which // on a concave region would put triangles outside it; the zero-area triangle keeps the edges paired. void ear_clip(const std::vector &pts, std::vector poly, const std::function &emit) { auto cross2 = [](const Vec2f &o, const Vec2f &a, const Vec2f &b) { return (a.x() - o.x()) * (b.y() - o.y()) - (a.y() - o.y()) * (b.x() - o.x()); }; // A corner flatter than this is collinear: several vertices along one edge of the triangle. Such // a corner is never an ear - the zero-area triangle would lie on the edge, where the neighbour, // seeing the same points from its side, may emit the same one. float flat = 0.f; { Vec2f lo = pts[size_t(poly[0])], hi = lo; for (int id : poly) { lo = lo.cwiseMin(pts[size_t(id)]); hi = hi.cwiseMax(pts[size_t(id)]); } flat = 1e-5f * (hi - lo).squaredNorm(); } while (poly.size() > 3) { const size_t n = poly.size(); bool found = false; for (size_t i = 0; i < n && !found; ++i) { const int ia = poly[(i + n - 1) % n], ib = poly[i], ic = poly[(i + 1) % n]; const Vec2f &a = pts[size_t(ia)], &b = pts[size_t(ib)], &c = pts[size_t(ic)]; const float area2 = cross2(a, b, c); if (area2 <= flat) continue; // reflex or flat corner const float eps = 1e-6f * area2; bool clean = true; size_t on_base = 0; for (size_t j = 0; j < n && clean; ++j) { const int ij = poly[j]; if (ij == ia || ij == ib || ij == ic) continue; const Vec2f &p = pts[size_t(ij)]; clean = !(cross2(a, b, p) > eps && cross2(b, c, p) > eps && cross2(c, a, p) > eps); // On the ear's base, between its ends: a run of vertices along the base, as when // several sit on one edge of the triangle. if (std::abs(cross2(c, a, p)) <= flat && (p - c).dot(a - c) > 0.f && (p - a).dot(c - a) > 0.f) ++on_base; } if (!clean) continue; if (on_base > 0) { // The base carries other vertices. When those are all that is left of the polygon, // in order from c round to a, fan them from b and finish; otherwise the ear is not // clear of the rest of the polygon. if (on_base + 3 != n) continue; for (size_t j = 0; j + 1 < n - 1; ++j) { const int u = poly[(i + 1 + j) % n], v = poly[(i + 2 + j) % n]; emit(ib, u, v); } poly.clear(); return; } emit(ia, ib, ic); poly.erase(poly.begin() + long(i)); found = true; } if (!found) { // Clip the smallest convex corner anyway, or failing that the flattest: a thin triangle // keeps every edge accounted for, and a convex one at least stays on the polygon's side. size_t pick = 0; float best = std::numeric_limits::max(); bool convex_found = false; for (size_t i = 0; i < n; ++i) { const Vec2f &a = pts[size_t(poly[(i + n - 1) % n])], &b = pts[size_t(poly[i])], &c = pts[size_t(poly[(i + 1) % n])]; const float m = cross2(a, b, c); if (m > flat && (!convex_found || m < best)) { best = m; pick = i; convex_found = true; } else if (!convex_found && std::abs(m) < best) { best = std::abs(m); pick = i; } } emit(poly[(pick + n - 1) % n], poly[pick], poly[(pick + 1) % n]); poly.erase(poly.begin() + long(pick)); } } if (poly.size() == 3) emit(poly[0], poly[1], poly[2]); } // Douglas-Peucker on an open polyline; endpoints always survive. void simplify_polyline(const std::vector &in, float tol, std::vector &out) { out.clear(); if (in.size() < 3) { out = in; return; } std::vector keep(in.size(), 0); keep.front() = keep.back() = 1; std::vector> stack = { { 0, in.size() - 1 } }; while (!stack.empty()) { const auto [i0, i1] = stack.back(); stack.pop_back(); if (i1 <= i0 + 1) continue; const Vec2f d = in[i1] - in[i0]; const float len = d.norm(); float best = -1.f; size_t bi = i0; for (size_t i = i0 + 1; i < i1; ++i) { const Vec2f r = in[i] - in[i0]; const float dist = (len > 1e-12f) ? std::abs(d.x() * r.y() - d.y() * r.x()) / len : r.norm(); if (dist > best) { best = dist; bi = i; } } if (best > tol) { keep[bi] = 1; stack.push_back({ i0, bi }); stack.push_back({ bi, i1 }); } } for (size_t i = 0; i < in.size(); ++i) if (keep[i]) out.push_back(in[i]); } } // namespace namespace { // The cutter's view of one crossing of the mid-level contour with a mesh edge. struct Crossing { Vec3f p, n; float t; // parameter from the edge's lower vertex id toward the higher int a = -1, b = -1; // the edge, lower vertex id first float sin_min = 1.f; // sine of the smallest angle a contour makes with the edge float dt_max = std::numeric_limits::max(); // room along the edge before a copy could // pass the copies of the contour's next vertex bool used = false; // some cut triangle's contour ends here bool no_seam = false; // a contour joins it straight to its neighbour on the edge: nothing between bool sharp = false; float thick = 0.f; // narrower side of the feature at the crossing, along the gradient int single = -1, lo = -1, hi = -1; int near_a = -1, near_b = -1; // the copy on a's side and on b's, when doubled }; struct Edge { std::vector xs; // sorted by t }; inline uint64_t step_edge_key(int a, int b) { if (a > b) std::swap(a, b); return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); } // What cut_mesh_at_steps() learns about the mesh before it cuts anything: the vertex heights (and the // nudged vertex positions), the crossings on every painted edge, and the verdict `ok` - whether this // is a step texture worth cutting at all. The verdict is what the prepare path probes on the coarse // mesh before it chooses how to refine, so refinement and cut agree on which textures are stepped. struct StepScan { bool ok = false; std::vector pos, vnormal; std::vector vh; float iso = 0.f, range = 0.f; std::unordered_map edges; std::pair sample_between(int a, int b, float t) const { const Vec3f p = pos[size_t(a)] + (pos[size_t(b)] - pos[size_t(a)]) * t; Vec3f n = vnormal[size_t(a)] + (vnormal[size_t(b)] - vnormal[size_t(a)]) * t; const float l = n.norm(); n = (l > 1e-12f) ? Vec3f(n / l) : vnormal[size_t(a)]; return std::make_pair(p, n); } bool side_of(float h) const { return h > iso; } }; StepScan scan_steps(const indexed_triangle_set &mesh, const std::vector ®ion, const HeightFieldSampler &sampler, float step_width_mm, float seam_gap_mm, float min_feature_mm, bool nudge) { StepScan scan; const size_t nv = mesh.vertices.size(), nt = mesh.indices.size(); if (!sampler || region.size() != nt || step_width_mm <= 0.f) return scan; // 1. Heights at the vertices of the cuttable triangles, along the same normals the bake will use. // Vertex positions are worked on in `pos`: a vertex that happens to sit within a texel of a step // is nudged off it below, and everything from the edge march to the output uses the nudged place. std::vector &pos = scan.pos; std::vector &vnormal = scan.vnormal; pos = mesh.vertices; vnormal = texture_displacement_vertex_normals(mesh); std::vector wanted(nv, 0); for (size_t t = 0; t < nt; ++t) if (region[t] != 0) for (int k = 0; k < 3; ++k) wanted[size_t(mesh.indices[t][k])] = 1; std::vector &vh = scan.vh; vh.assign(nv, 0.f); tbb::parallel_for(tbb::blocked_range(0, nv), [&](const tbb::blocked_range &r) { for (size_t v = r.begin(); v < r.end(); ++v) if (wanted[v]) vh[v] = sampler(pos[v], vnormal[v]); }); float hmin = std::numeric_limits::max(), hmax = -hmin; for (size_t v = 0; v < nv; ++v) if (wanted[v]) { hmin = std::min(hmin, vh[v]); hmax = std::max(hmax, vh[v]); } const float range = scan.range = hmax - hmin; if (!(range > 1e-6f)) return scan; const float iso = scan.iso = 0.5f * (hmin + hmax); // Only a texture that is mostly steps is cut: one whose heights sit at two levels with little in // between (a grid, a knurl, a logo). On a smooth or noisy relief the mid-level contour runs through // every triangle without being a step anywhere, and cutting along it would only multiply the // triangles; the chord-based refinement is the right tool there. Judged from the vertex heights, // which sample the whole painted surface. { size_t near_level = 0, total = 0; for (size_t v = 0; v < nv; ++v) if (wanted[v]) { ++total; near_level += std::abs(vh[v] - iso) > 0.35f * range; } if (total == 0 || double(near_level) < 0.6 * double(total)) return scan; } // 1b. A vertex inside the blend of a sharp step - the texture's bilinear ramp is a texel wide - would // be displaced to a height between the two sides, and every triangle at it would ramp. Such a vertex // is nudged along the surface away from the step, down its own side's slope, until it samples a // pure value; a step's width or so. Only where the surface is flat around it: a vertex on a crease // of the model (a box edge) stays where it is. Skipped by a probe, which only wants the verdict. if (nudge) { std::vector creased(nv, 0); std::vector first_normal(nv, Vec3f::Zero()); std::vector shortest(nv, std::numeric_limits::max()); // shortest edge at the vertex for (size_t t = 0; t < nt; ++t) { const auto &tri = mesh.indices[t]; const Vec3f &a = pos[size_t(tri[0])]; Vec3f fn = (pos[size_t(tri[1])] - a).cross(pos[size_t(tri[2])] - a); const float fl = fn.norm(); if (fl < 1e-12f) continue; fn /= fl; for (int k = 0; k < 3; ++k) { Vec3f &f0 = first_normal[size_t(tri[k])]; if (f0.isZero()) f0 = fn; else if (f0.dot(fn) < 0.985f) creased[size_t(tri[k])] = 1; // ~10 degrees const float el = (pos[size_t(tri[(k + 1) % 3])] - pos[size_t(tri[k])]).norm(); shortest[size_t(tri[k])] = std::min(shortest[size_t(tri[k])], el); shortest[size_t(tri[(k + 1) % 3])] = std::min(shortest[size_t(tri[(k + 1) % 3])], el); } } tbb::parallel_for(tbb::blocked_range(0, nv), [&](const tbb::blocked_range &r) { for (size_t v = r.begin(); v < r.end(); ++v) { if (!wanted[v] || creased[v] || std::abs(vh[v] - iso) > 0.47f * range) continue; const Vec3f &n = vnormal[v]; const Vec3f ax = (std::abs(n.x()) < 0.9f) ? Vec3f::UnitX() : Vec3f::UnitY(); const Vec3f t1 = n.cross(ax).normalized(), t2 = n.cross(t1).normalized(); const float d = step_width_mm; const float gx = sampler(pos[v] + t1 * d, n) - sampler(pos[v] - t1 * d, n); const float gy = sampler(pos[v] + t2 * d, n) - sampler(pos[v] - t2 * d, n); Vec3f g = t1 * gx + t2 * gy; const float gl = g.norm(); if (gl < 0.25f * range) continue; // not at a step: a slope, which the chord test handles g /= gl; // Down the slope for a low vertex, up it for a high one, in steps of half a texel, and // never by more than a fraction of the shortest edge at the vertex: on a finely refined // mesh a texel-sized move would fold the triangles around it. const Vec3f dir = (vh[v] > iso) ? g : Vec3f(-g); Vec3f best = pos[v]; float bh = vh[v]; const float limit = std::min(2.f * step_width_mm, 0.3f * shortest[v]); for (int i = 1; i <= 8; ++i) { const float dist = 0.25f * step_width_mm * float(i); if (dist > limit) break; const Vec3f q = pos[v] + dir * dist; const float h = sampler(q, n); if ((h > iso) != (vh[v] > iso)) break; // crossed the step: the nudge would change the vertex's side best = q; bh = h; if (std::abs(h - iso) > 0.48f * range) break; } if (std::abs(bh - iso) > std::abs(vh[v] - iso)) { pos[v] = best; vh[v] = bh; } } }); } const auto sample_between = [&scan](int a, int b, float t) { return scan.sample_between(a, b, t); }; const auto side_of = [&scan](float h) { return scan.side_of(h); }; // 2. Crossings on the edges, found on the field itself - on a 1 mm edge over a texture with 30 um // texels, interpolating the endpoint heights could put a step anywhere along the edge. Both // triangles on an edge see the same crossings, which is what keeps the cut conformal. auto &edges = scan.edges; edges.reserve(nt); const auto find_crossings = [&](int a, int b, Edge &edge) { if (a > b) std::swap(a, b); const float len = (pos[size_t(b)] - pos[size_t(a)]).norm(); const int steps = std::clamp(int(std::ceil(len / (0.5f * step_width_mm))), 4, 128); float t0 = 0.f, h0 = vh[size_t(a)] - iso; for (int i = 1; i <= steps; ++i) { const float ti = float(i) / float(steps); const auto [pp, nn] = sample_between(a, b, ti); const float hi_ = sampler(pp, nn) - iso; if (h0 * hi_ < 0.f || (hi_ == 0.f && h0 != 0.f)) { float lo_t = t0, hi_t = ti, lo_h = h0; for (int k = 0; k < 10; ++k) { const float tm = 0.5f * (lo_t + hi_t); const auto [pm, nm] = sample_between(a, b, tm); if (lo_h * (sampler(pm, nm) - iso) <= 0.f) hi_t = tm; else { lo_t = tm; } } Crossing c; c.a = a; c.b = b; // Never exactly on a vertex: that would make a zero-area triangle of the split. c.t = std::clamp(0.5f * (lo_t + hi_t), 1e-3f, 1.f - 1e-3f); std::tie(c.p, c.n) = sample_between(a, b, c.t); // Sharp means the field changes by at least half its range across `step_width_mm` in // the steepest tangent direction - a step, not a slope that merely passes mid-level. const Vec3f ax = (std::abs(c.n.x()) < 0.9f) ? Vec3f::UnitX() : Vec3f::UnitY(); const Vec3f t1v = c.n.cross(ax).normalized(), t2v = c.n.cross(t1v).normalized(); const float d = 0.5f * step_width_mm; const float gx = sampler(c.p + t1v * d, c.n) - sampler(c.p - t1v * d, c.n); const float gy = sampler(c.p + t2v * d, c.n) - sampler(c.p - t2v * d, c.n); const float gl = std::sqrt(gx * gx + gy * gy); c.sharp = gl >= 0.5f * range; // How wide the feature is on either side of the step, along the gradient: the // distance until the field returns to mid-level, in steps of half the step width, up // to `thick_max`. A texture whose features are only a few texels wide has no pure // interior for a seam copy to land in and is left to refinement (see the gate below). { const float thick_max = 4.f * step_width_mm; c.thick = thick_max; if (gl > 1e-12f) { const Vec3f g = (t1v * gx + t2v * gy) / gl; for (int sgn = -1; sgn <= 1; sgn += 2) { const bool high = sgn > 0; // uphill along the gradient for (int i = 1; i <= 8; ++i) { const float dist = 0.5f * step_width_mm * float(i); if (dist >= c.thick) break; if (side_of(sampler(c.p + g * (float(sgn) * dist), c.n)) != high) { c.thick = std::min(c.thick, dist); break; } } } } } edge.xs.push_back(c); } t0 = ti; h0 = hi_; } // A pair of crossings closer than the smallest feature the mesh is meant to carry is a feature // too thin to print, and one closer than the seam gap has no room for two seams: dropping both // leaves the surface flat there, at the side around it. const float thinnest = std::max(min_feature_mm, seam_gap_mm); for (size_t i = 0; i + 1 < edge.xs.size();) { if ((edge.xs[i + 1].t - edge.xs[i].t) * len < thinnest) edge.xs.erase(edge.xs.begin() + long(i), edge.xs.begin() + long(i) + 2); else ++i; } // Parity: the endpoints' sides say whether the count must be odd or even; the march starts and // ends on the vertex heights themselves, so a mismatch is a numerical accident. Drop the lot // then - a triangle split on a wrong count is worse than a ramp there. const bool odd_expected = side_of(vh[size_t(a)]) != side_of(vh[size_t(b)]); if ((edge.xs.size() % 2 == 1) != odd_expected) edge.xs.clear(); }; // Edges to examine, in a fixed order so the crossings can be found in parallel. std::vector edge_keys; for (size_t t = 0; t < nt; ++t) { if (region[t] == 0) continue; const auto &tri = mesh.indices[t]; for (int e = 0; e < 3; ++e) edge_keys.push_back(step_edge_key(tri[e], tri[(e + 1) % 3])); } std::sort(edge_keys.begin(), edge_keys.end()); edge_keys.erase(std::unique(edge_keys.begin(), edge_keys.end()), edge_keys.end()); std::vector found(edge_keys.size()); tbb::parallel_for(tbb::blocked_range(0, edge_keys.size()), [&](const tbb::blocked_range &r) { for (size_t i = r.begin(); i < r.end(); ++i) find_crossings(int(edge_keys[i] >> 32), int(uint32_t(edge_keys[i])), found[i]); }); size_t crossings = 0, sharp = 0; double thick_sum = 0.; for (size_t i = 0; i < edge_keys.size(); ++i) { for (const Crossing &c : found[i].xs) { ++crossings; sharp += c.sharp; thick_sum += c.thick; } edges.emplace(edge_keys[i], std::move(found[i])); } // The mid-level contour has to be a step nearly everywhere it is crossed, or this is not a step // texture: a noisy relief that happens to sit at two levels is crossed all over, mostly gently. if (crossings == 0 || double(sharp) < 0.8 * double(crossings)) return scan; // And the features have to be wider than the step itself. A texture whose blobs are only a few // texels across (noise, a fine grain) is bimodal and sharp at every crossing, yet has no pure // interior for the seam copies to land in, and its contour runs through every triangle: cutting it // doubles the triangle count for walls the same size as the interpolation blur. Judged from the // mean feature thickness at the crossings, measured in step widths (2.25 = four to five texels). if (thick_sum < 2.25 * double(step_width_mm) * double(crossings)) return scan; scan.ok = true; return scan; } } // namespace bool texture_has_steps_to_cut(const indexed_triangle_set &mesh, const std::vector ®ion, const HeightFieldSampler &sampler, float step_width_mm, float seam_gap_mm, float min_feature_mm) { return scan_steps(mesh, region, sampler, step_width_mm, seam_gap_mm, min_feature_mm, /*nudge*/ false).ok; } indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const std::vector ®ion, const HeightFieldSampler &sampler, float step_width_mm, float seam_gap_mm, float min_feature_mm, std::vector *out_source, size_t *out_cut_count) { const size_t nv = mesh.vertices.size(), nt = mesh.indices.size(); if (out_cut_count) *out_cut_count = 0; const auto passthrough = [&]() { if (out_source) { out_source->resize(nt); std::iota(out_source->begin(), out_source->end(), 0); } return mesh; }; if (!sampler || region.size() != nt || step_width_mm <= 0.f) return passthrough(); StepScan scan = scan_steps(mesh, region, sampler, step_width_mm, seam_gap_mm, min_feature_mm, /*nudge*/ true); if (!scan.ok) return passthrough(); std::vector &pos = scan.pos; const std::vector &vnormal = scan.vnormal; std::vector &vh = scan.vh; const float iso = scan.iso, range = scan.range; auto &edges = scan.edges; const auto sample_between = [&scan](int a, int b, float t) { return scan.sample_between(a, b, t); }; const auto side_of = [&scan](float h) { return scan.side_of(h); }; const auto edge_key = [](int a, int b) { return step_edge_key(a, b); }; (void) vnormal; (void) range; (void) iso; // 3. Per triangle: the perimeter as a loop of corners and crossings, and the contour inside the // triangle traced from a local raster of the field (marching squares at half the step width), as // polylines from one crossing to another. The trace is what makes a corner of the pattern come out // as a corner instead of a chord clipping it, and what pairs the crossings up - no guessing. A // polyline is kept only when its two ends land on distinct crossings the edge march found; // otherwise the triangle is split at its crossings without a seam (the neighbours' cuts still meet // no T-junction) and the surface ramps there. Unpainted neighbours of a crossed edge are split the // same way. struct Chain { int i = -1, j = -1; // crossing indices in the loop, from i to j std::vector pts; // interior vertices, from i toward j std::vector lo, hi; // output copies of the interior vertices (same when single) float clearance = std::numeric_limits::max(); // distance to the nearest other contour }; struct Loop { std::vector ids; // perimeter entries: corner vertex ids (>= 0) or -(1 + crossing index) std::vector xs; // crossings in perimeter order std::vector xpos; // their positions in ids std::vector chains; std::vector partner; // per crossing: the crossing its chain leads to std::vector chain_of; std::vector no_seam; // crossings joined to their neighbour on the edge with nothing between Vec3f N = Vec3f::Zero(); bool ok = false; }; const auto walk_edge = [&](int a, int b, std::vector &out) { auto it = edges.find(edge_key(a, b)); if (it == edges.end()) return; Edge &edge = it->second; if (a < b) for (auto &c : edge.xs) out.push_back(&c); else for (auto ci = edge.xs.rbegin(); ci != edge.xs.rend(); ++ci) out.push_back(&*ci); }; std::vector loops(nt); for (size_t t = 0; t < nt; ++t) { const auto &tri = mesh.indices[t]; Loop &L = loops[t]; for (int e = 0; e < 3; ++e) { L.ids.push_back(tri[e]); std::vector on_edge; walk_edge(tri[e], tri[(e + 1) % 3], on_edge); for (Crossing *c : on_edge) { L.xpos.push_back(int(L.ids.size())); L.ids.push_back(-(1 + int(L.xs.size()))); L.xs.push_back(c); } } const Vec3f &a0 = pos[size_t(tri[0])]; L.N = (pos[size_t(tri[1])] - a0).cross(pos[size_t(tri[2])] - a0); const float nl = L.N.norm(); if (nl > 1e-12f) L.N /= nl; } const float cell = 0.5f * step_width_mm; const auto trace = [&](size_t t) { Loop &L = loops[t]; const int n = int(L.xs.size()); const auto &tri = mesh.indices[t]; if (n == 0 || region[t] == 0) return; if (n % 2 != 0 || L.N.squaredNorm() < 0.5f) return; // Local frame on the triangle's plane; the loop runs counter-clockwise in it. const Vec3f &A = pos[size_t(tri[0])], &B = pos[size_t(tri[1])], &C = pos[size_t(tri[2])]; const Vec3f U = (B - A).normalized(), V = L.N.cross(U); const auto to2 = [&](const Vec3f &p) { return Vec2f((p - A).dot(U), (p - A).dot(V)); }; const auto to3 = [&](const Vec2f &q) { return Vec3f(A + U * q.x() + V * q.y()); }; const Vec2f a2 = to2(A), b2 = to2(B), c2 = to2(C); const float det = (b2.x() - a2.x()) * (c2.y() - a2.y()) - (c2.x() - a2.x()) * (b2.y() - a2.y()); if (std::abs(det) < 1e-12f) return; // A barycentric lattice over the triangle, dense enough that its boundary rows are at least as // fine as the edge march. Its boundary points take their side from the march - the lower // corner's side, flipped at every crossing passed - so the contour traced through the lattice // ends exactly at the crossings the neighbours share; no clipping, no matching. A conflict // between that and the far corner's own side (an edge whose crossings the march dropped for // parity) leaves the triangle uncut. const float longest = std::sqrt(std::max({ (b2 - a2).squaredNorm(), (c2 - b2).squaredNorm(), (a2 - c2).squaredNorm() })); const int N = std::clamp(int(std::ceil(longest / cell)), 4, 96); const float h = longest / float(N); const int W = N + 1; // Lattice point (j, k): A + (B - A) j/N + (C - A) k/N, j + k <= N. Edge 0 (A->B) is k == 0, // edge 1 (B->C) is j + k == N, edge 2 (C->A) is j == 0. const auto at2 = [&](int j, int k) { return Vec2f(a2 + (b2 - a2) * (float(j) / float(N)) + (c2 - a2) * (float(k) / float(N))); }; std::vector f(size_t(W) * size_t(W), 0.f); std::vector sign(size_t(W) * size_t(W), 0); const auto id = [&](int j, int k) { return size_t(k) * size_t(W) + size_t(j); }; for (int k = 0; k <= N; ++k) for (int j = 0; j + k <= N; ++j) { const float wb = float(j) / float(N), wc = float(k) / float(N), wa = 1.f - wb - wc; Vec3f nn = wa * vnormal[size_t(tri[0])] + wb * vnormal[size_t(tri[1])] + wc * vnormal[size_t(tri[2])]; const float nl = nn.norm(); nn = (nl > 1e-12f) ? Vec3f(nn / nl) : L.N; const float v = sampler(A + (B - A) * wb + (C - A) * wc, nn) - iso; f[id(j, k)] = v; sign[id(j, k)] = v > 0.f ? 1 : -1; } // Boundary sides from the march. Each edge walks from its first corner in loop order; the // crossings on it are in that order in L.xs. Position along edge e of lattice point m/N. const auto edge_point = [&](int e, int m) -> std::pair { // (j, k) return e == 0 ? std::make_pair(m, 0) : e == 1 ? std::make_pair(N - m, m) : std::make_pair(0, N - m); }; // The crossings of edge e, with their parameter from the edge's first corner in loop order. std::vector>> edge_x(3); { int e = -1, k = 0; for (int idv : L.ids) { if (idv >= 0) { ++e; continue; } const Crossing &c = *L.xs[size_t(k)]; // c.t runs from the lower vertex id; the loop walks tri[e] -> tri[(e + 1) % 3]. const float u = (tri[e] < tri[(e + 1) % 3]) ? c.t : 1.f - c.t; edge_x[size_t(e)].push_back({ u, k }); ++k; } } for (int e = 0; e < 3; ++e) { int8_t s = side_of(vh[size_t(tri[e])]) ? 1 : -1; size_t next = 0; for (int m = 0; m <= N; ++m) { const float u = float(m) / float(N); while (next < edge_x[size_t(e)].size() && edge_x[size_t(e)][next].first < u) { s = -s; ++next; } const auto [j, k] = edge_point(e, m); sign[id(j, k)] = s; // Keep the value on the march's side of zero, so interpolation toward the interior // does not put a crossing where the march has none - and clear of zero, so a contour // that does pass between this point and the next row is not put on the boundary. if ((f[id(j, k)] > 0.f) != (s > 0)) f[id(j, k)] = float(s) * std::max(std::abs(f[id(j, k)]), 0.05f * range); } if ((s > 0) != side_of(vh[size_t(tri[(e + 1) % 3])])) return; // parity conflict on this edge } // Marching triangles. A contour point lives on a lattice edge, keyed by its lower point and // direction: 0 (j,k)-(j+1,k), 1 (j,k)-(j,k+1), 2 (j+1,k)-(j,k+1). A boundary lattice edge with // a sign change carries exactly the march crossing in its interval, and is that crossing. std::vector pts; std::vector pt_x; // crossing index, or -1 for an interior point std::unordered_map point_of; std::vector> link; bool bad = false; const auto point_on = [&](int j, int k, int dir) -> int { const uint32_t key = (uint32_t(j) << 16) | (uint32_t(k) << 2) | uint32_t(dir); auto it = point_of.find(key); if (it != point_of.end()) return it->second; // Endpoints (j0, k0) and (jb, kb). const int j0 = dir == 2 ? j + 1 : j, k0 = k; const int jb = dir == 0 ? j + 1 : j, kb = dir == 0 ? k : k + 1; int xk = -1; Vec2f p; int e = -1; float u0 = 0.f, u1 = 0.f; if (k0 == 0 && kb == 0) { e = 0; u0 = float(j0) / float(N); u1 = float(jb) / float(N); } else if (j0 + k0 == N && jb + kb == N) { e = 1; u0 = float(k0) / float(N); u1 = float(kb) / float(N); } else if (j0 == 0 && jb == 0) { e = 2; u0 = float(N - k0) / float(N); u1 = float(N - kb) / float(N); } if (e >= 0) { const float lo = std::min(u0, u1), hi = std::max(u0, u1); for (const auto &[u, k_] : edge_x[size_t(e)]) if (u >= lo && u < hi) { if (xk >= 0) bad = true; xk = k_; } if (xk < 0) { bad = true; p = 0.5f * (at2(j0, k0) + at2(jb, kb)); } else p = to2(L.xs[size_t(xk)]->p); } else { const float fa = f[id(j0, k0)], fb = f[id(jb, kb)]; const float s = (fa != fb) ? std::clamp(fa / (fa - fb), 0.f, 1.f) : 0.5f; p = at2(j0, k0) + (at2(jb, kb) - at2(j0, k0)) * s; } const int pid = int(pts.size()); pts.push_back(p); pt_x.push_back(xk); link.push_back({ -1, -1 }); point_of.emplace(key, pid); return pid; }; const auto connect = [&](int p, int q) { for (int *slot : { &link[size_t(p)][0], &link[size_t(p)][1] }) if (*slot < 0) { *slot = q; break; } for (int *slot : { &link[size_t(q)][0], &link[size_t(q)][1] }) if (*slot < 0) { *slot = p; break; } }; // Up triangle (j,k),(j+1,k),(j,k+1): edges dir 0 at (j,k), dir 1 at (j,k), dir 2 at (j,k). // Down triangle (j+1,k),(j+1,k+1),(j,k+1): dir 1 at (j+1,k), dir 0 at (j,k+1), dir 2 at (j,k). for (int k = 0; k < N; ++k) for (int j = 0; j + k < N; ++j) { { const int8_t s0_ = sign[id(j, k)], s1_ = sign[id(j + 1, k)], s2_ = sign[id(j, k + 1)]; if (!(s0_ == s1_ && s1_ == s2_)) { std::vector ends; if (s0_ != s1_) ends.push_back(point_on(j, k, 0)); if (s0_ != s2_) ends.push_back(point_on(j, k, 1)); if (s1_ != s2_) ends.push_back(point_on(j, k, 2)); if (ends.size() == 2) connect(ends[0], ends[1]); } } if (j + k + 1 < N) { const int8_t s0_ = sign[id(j + 1, k)], s1_ = sign[id(j + 1, k + 1)], s2_ = sign[id(j, k + 1)]; if (!(s0_ == s1_ && s1_ == s2_)) { std::vector ends; if (s0_ != s1_) ends.push_back(point_on(j + 1, k, 1)); if (s1_ != s2_) ends.push_back(point_on(j, k + 1, 0)); if (s0_ != s2_) ends.push_back(point_on(j, k, 2)); if (ends.size() == 2) connect(ends[0], ends[1]); } } } if (bad) return; // Chains from every crossing to the crossing it leads to. Closed loops never touch the // boundary and are left alone - refinement resolves a feature that sits entirely inside a // triangle. std::vector matched(static_cast(n), -1); std::vector chains; std::vector> raws; // the traced polyline of each chain, crossing to crossing std::vector seen(pts.size(), 0); for (size_t s = 0; s < pts.size(); ++s) { if (seen[s] || pt_x[s] < 0) continue; std::vector full; int prev = -1, cur = int(s); while (cur >= 0 && !seen[size_t(cur)]) { seen[size_t(cur)] = 1; full.push_back(pts[size_t(cur)]); if (pt_x[size_t(cur)] >= 0 && cur != int(s)) break; const int nx0 = link[size_t(cur)][0], nx1 = link[size_t(cur)][1]; const int next = (nx0 != prev) ? nx0 : nx1; prev = cur; cur = next; } if (cur < 0 || pt_x[size_t(cur)] < 0 || cur == int(s)) return; // a contour that starts at a crossing and ends nowhere: the lattice is inconsistent Chain ch; ch.i = pt_x[s]; ch.j = pt_x[size_t(cur)]; if (matched[size_t(ch.i)] >= 0 || matched[size_t(ch.j)] >= 0 || ch.i == ch.j) return; matched[size_t(ch.i)] = ch.j; matched[size_t(ch.j)] = ch.i; chains.push_back(std::move(ch)); raws.push_back(std::move(full)); } // How close each contour comes to another: two sides of a thin feature. Simplifying either by // the usual tolerance, or offsetting its copies by the usual gap, could then push it across // the other, so both are scaled to the clearance. Segment-to-segment over every pair. { const auto seg_dist = [](const Vec2f &a, const Vec2f &b, const Vec2f &c, const Vec2f &d) { const auto pt_seg = [](const Vec2f &q, const Vec2f &u, const Vec2f &v) { const Vec2f uv = v - u; const float t = std::clamp((q - u).dot(uv) / std::max(uv.squaredNorm(), 1e-12f), 0.f, 1.f); return (q - (u + uv * t)).norm(); }; return std::min({ pt_seg(a, c, d), pt_seg(b, c, d), pt_seg(c, a, b), pt_seg(d, a, b) }); }; for (size_t x = 0; x < raws.size(); ++x) for (size_t y = x + 1; y < raws.size(); ++y) { float best = std::min(chains[x].clearance, chains[y].clearance); for (size_t i = 0; i + 1 < raws[x].size(); ++i) for (size_t j = 0; j + 1 < raws[y].size(); ++j) best = std::min(best, seg_dist(raws[x][i], raws[x][i + 1], raws[y][j], raws[y][j + 1])); chains[x].clearance = std::min(chains[x].clearance, best); chains[y].clearance = std::min(chains[y].clearance, best); } } for (size_t c = 0; c < chains.size(); ++c) { Chain &ch = chains[c]; const std::vector &full = raws[c]; const float tol = std::min(h, 0.25f * ch.clearance); const float gap = std::min(seam_gap_mm, 0.5f * ch.clearance); std::vector simple; simplify_polyline(full, tol, simple); // A vertex within the seam gap of the previous one, or of the far end, leaves its copies no // room to clear the blend of the step, and a corner that close to an edge is within the // seam's own width anyway: it is merged into its neighbour. { std::vector spaced; spaced.push_back(simple.front()); for (size_t m = 1; m + 1 < simple.size(); ++m) if ((simple[m] - spaced.back()).norm() >= gap && (simple[m] - simple.back()).norm() >= gap) spaced.push_back(simple[m]); spaced.push_back(simple.back()); simple = std::move(spaced); } const int m_loop = int(L.ids.size()); const bool adjacent = (L.xpos[size_t(ch.j)] - L.xpos[size_t(ch.i)] + m_loop) % m_loop == 1 || (L.xpos[size_t(ch.i)] - L.xpos[size_t(ch.j)] + m_loop) % m_loop == 1; if (simple.size() == 2 && full.size() > 2 && adjacent) { // Never down to a bare chord between two crossings next to each other on one edge: the // shallow pocket between them would be joined along the edge itself, and the regions on // either side would overlap. The raw point farthest from the chord stays. const Vec2f d = full.back() - full.front(); const float dl = std::max(d.norm(), 1e-9f); size_t bi = full.size() / 2; float bd = 0.1f * tol; // below this the contour is straight: take its middle point for (size_t m = 1; m + 1 < full.size(); ++m) { const Vec2f r = full[m] - full.front(); const float dist = std::abs(d.x() * r.y() - d.y() * r.x()) / dl; if (dist > bd) { bd = dist; bi = m; } } simple.insert(simple.begin() + 1, full[bi]); } // Hairpins - the contour doubling back on itself with less than the seam gap between the // two sides, the tip of a grain line thinner than the gap - would give a region polygon // whose two sides cross. The spike vertex goes, and the feature is flattened there, as a // thin feature on an edge is. for (bool again = true; again;) { again = false; for (size_t m = 1; m + 1 < simple.size(); ++m) { const Vec2f d1 = simple[m] - simple[m - 1], d2 = simple[m + 1] - simple[m]; const float l1 = d1.norm(), l2 = d2.norm(); if (l1 < 1e-9f || l2 < 1e-9f) { simple.erase(simple.begin() + long(m)); again = true; break; } if (d1.dot(d2) / (l1 * l2) > -0.5f) continue; // turns less than 120 degrees: a corner, not a hairpin // Distance between the two sides: the shorter side's far end to the other side. const Vec2f &tip = simple[m]; const auto dist_to = [&](const Vec2f &q, const Vec2f &a, const Vec2f &b) { const Vec2f ab = b - a; const float t = std::clamp((q - a).dot(ab) / std::max(ab.squaredNorm(), 1e-12f), 0.f, 1.f); return (q - (a + ab * t)).norm(); }; const float between = (l1 < l2) ? dist_to(simple[m - 1], tip, simple[m + 1]) : dist_to(simple[m + 1], simple[m - 1], tip); if (between < seam_gap_mm) { simple.erase(simple.begin() + long(m)); again = true; break; } } } for (size_t m = 1; m + 1 < simple.size(); ++m) ch.pts.push_back(to3(simple[m])); if (ch.pts.empty() && adjacent) { // Two crossings next to each other on one edge and the contour, once simplified, going // straight from one to the other: an empty pocket. There is nothing to wall off - a wall // along the edge would be a zero-area strip, and the neighbour would lay its own on top // - so neither crossing gets a seam. Recorded here and applied once the tracing is done. L.no_seam.push_back(ch.i); L.no_seam.push_back(ch.j); } } for (int k = 0; k < n; ++k) if (matched[size_t(k)] < 0) return; L.chains = std::move(chains); L.partner = std::move(matched); L.chain_of.assign(static_cast(n), -1); for (size_t c = 0; c < L.chains.size(); ++c) { L.chain_of[size_t(L.chains[c].i)] = int(c); L.chain_of[size_t(L.chains[c].j)] = int(c); } L.ok = true; }; tbb::parallel_for(tbb::blocked_range(0, nt), [&](const tbb::blocked_range &r) { for (size_t t = r.begin(); t < r.end(); ++t) trace(t); }); size_t cut_count = 0; for (size_t t = 0; t < nt; ++t) { cut_count += loops[t].ok ? 1 : 0; if (loops[t].ok) for (int k : loops[t].no_seam) loops[t].xs[size_t(k)]->no_seam = true; } // 4. Which crossings a contour reaches, and how obliquely: the copies of a doubled crossing are moved // apart along the edge, so the shallower the contour meets the edge the further apart they need to // be to sit the same distance clear of it. for (size_t t = 0; t < nt; ++t) { Loop &L = loops[t]; if (!L.ok) continue; for (auto &ch : L.chains) { Crossing &p = *L.xs[size_t(ch.i)], &q = *L.xs[size_t(ch.j)]; p.used = q.used = true; const Vec3f p_next = ch.pts.empty() ? q.p : ch.pts.front(); const Vec3f q_prev = ch.pts.empty() ? p.p : ch.pts.back(); for (auto [c, toward] : { std::make_pair(&p, p_next), std::make_pair(&q, q_prev) }) { Vec3f d = toward - c->p; const float dl = d.norm(); if (dl < 1e-12f) continue; const Vec3f ed = (pos[size_t(c->b)] - pos[size_t(c->a)]).normalized(); c->sin_min = std::min(c->sin_min, ed.cross(d / dl).norm()); // Only the part of a move along the edge that runs along the contour brings the copy // toward the next vertex's copies. With no interior vertex the chord's two ends share // it; otherwise the interior copies, placed first, say how much room there is. if (ch.pts.empty()) c->dt_max = std::min(c->dt_max, 0.45f * dl / std::max(std::abs(ed.dot(d / dl)), 1e-3f)); } } } // 5. Output vertices: the originals, then one or two per crossing and per interior contour vertex. // A sharp crossing that a contour reaches is doubled, into two vertices on the edge either side of // it, each on its own side of the contour; a triangle on that edge that is not cut itself simply // carries both on its perimeter, one after the other, so the cut neighbour's wall still meets a // closed surface. Keeping the copies on the edge is what keeps every triangle's split planar and // inside the triangle. A crossing that is not sharp, or that no contour reaches, stays one vertex // and the surface ramps there. indexed_triangle_set out; out.vertices = pos; const float half_gap = 0.5f * seam_gap_mm; // Interior contour vertices: doubled when the contour's ends are, offset across the contour along // the bisector of the two segments meeting there, toward the high side; mitre limited, and held // inside the triangle - a copy pushed across an edge would fold the region over the neighbour. for (size_t t = 0; t < nt; ++t) { Loop &L = loops[t]; if (!L.ok) continue; const auto &tri = mesh.indices[t]; const Vec3f &A = pos[size_t(tri[0])], &B = pos[size_t(tri[1])], &C = pos[size_t(tri[2])]; const Vec3f U = (B - A).normalized(), V = L.N.cross(U); const Vec2f a2(0.f, 0.f), b2((B - A).dot(U), (B - A).dot(V)), c2((C - A).dot(U), (C - A).dot(V)); const float det = (b2.x() - a2.x()) * (c2.y() - a2.y()) - (c2.x() - a2.x()) * (b2.y() - a2.y()); const auto bary2 = [&](const Vec3f &p) { const Vec2f q((p - A).dot(U), (p - A).dot(V)); const float wb = ((q.x() - a2.x()) * (c2.y() - a2.y()) - (c2.x() - a2.x()) * (q.y() - a2.y())) / det; const float wc = ((b2.x() - a2.x()) * (q.y() - a2.y()) - (q.x() - a2.x()) * (b2.y() - a2.y())) / det; return Vec3f(1.f - wb - wc, wb, wc); }; // The largest fraction of the offset that keeps the copy inside, with a little to spare. const auto inside_fraction = [&](const Vec3f &p, const Vec3f &off) { const Vec3f w0 = bary2(p), w1 = bary2(p + off); float lambda = 1.f; for (int i = 0; i < 3; ++i) if (w1[i] < 0.f && w0[i] > w1[i]) lambda = std::min(lambda, w0[i] / (w0[i] - w1[i])); return 0.9f * std::max(lambda, 0.f); }; const bool s0 = side_of(vh[size_t(mesh.indices[t][0])]); for (auto &ch : L.chains) { const Crossing &p = *L.xs[size_t(ch.i)], &q = *L.xs[size_t(ch.j)]; const bool doubled = p.sharp && p.used && !p.no_seam && q.sharp && q.used && !q.no_seam; // The arc right after crossing ch.i lies on one side; which way that is from the contour // says where "high" is. The arc's side: the loop's first corner's, flipped once per // crossing passed, so after crossing index k it is !s0 for even k and s0 for odd. const bool arc_high = (ch.i % 2 == 0) ? !s0 : s0; const int after_pos = L.xpos[size_t(ch.i)] + 1; const int entry = L.ids[size_t(after_pos) % L.ids.size()]; const Vec3f ref = entry >= 0 ? pos[size_t(entry)] : L.xs[size_t(-entry - 1)]->p; const size_t m = ch.pts.size(); ch.lo.resize(m); ch.hi.resize(m); // The left of the walk from i to j is one side of the contour all along it; whether that // side is high is settled once, at the first segment, where the arc is a known reference. // The direction the contour leaves the crossing in, taken over a usable length: the first // interior vertex may sit right next to it. Vec3f d_first = q.p - p.p; for (size_t k = 0; k < m; ++k) if ((ch.pts[k] - p.p).norm() > 0.25f * seam_gap_mm) { d_first = ch.pts[k] - p.p; break; } d_first.normalize(); const bool left_high = (L.N.cross(d_first).dot(ref - p.p) > 0.f) == arc_high; for (size_t k = 0; k < m; ++k) { if (!doubled) { ch.lo[k] = ch.hi[k] = int(out.vertices.size()); out.vertices.push_back(ch.pts[k]); continue; } const Vec3f prev = (k == 0) ? p.p : ch.pts[k - 1]; const Vec3f next = (k + 1 == m) ? q.p : ch.pts[k + 1]; Vec3f d1 = (ch.pts[k] - prev).normalized(), d2 = (next - ch.pts[k]).normalized(); Vec3f n1 = L.N.cross(d1), n2 = L.N.cross(d2); Vec3f bis = n1 + n2; float bl = bis.norm(); Vec3f off; if (bl < 1e-6f) off = n1; else { bis /= bl; // Mitre: the offset polyline stays `half_gap` from both segments, up to twice that. const float cos_half = std::max(bis.dot(n1), 0.5f); off = bis / cos_half; } if (!left_high) off = -off; // No further along either segment than half its length, so copies never cross their // neighbours'; across the segments the offset is free. const float ol = off.norm(); const float reach = std::min({ half_gap, 0.45f * ch.clearance, 0.45f * (ch.pts[k] - prev).norm() / std::max(std::abs(off.dot(d1)) / ol, 1e-3f), 0.45f * (next - ch.pts[k]).norm() / std::max(std::abs(off.dot(d2)) / ol, 1e-3f) }); const Vec3f off_lo = -off * (reach * inside_fraction(ch.pts[k], -off * reach)); const Vec3f off_hi = off * (reach * inside_fraction(ch.pts[k], off * reach)); // As for the crossings: each copy at the first of a few positions outward that samples // pure, or the vertex stays single. const auto place = [&](const Vec3f &o, bool high) -> int { for (int step = 1; step <= 4; ++step) { const Vec3f q = ch.pts[k] + o * (0.5f * float(step)); if (step > 2 && inside_fraction(ch.pts[k], q - ch.pts[k]) < 0.999f) break; const float h = sampler(q, L.N) - iso; if ((h > 0.f) == high && std::abs(h) >= 0.3f * range) { out.vertices.push_back(q); return int(out.vertices.size()) - 1; } } return -1; }; const int il = place(off_lo, false), ih = place(off_hi, true); if (il < 0 || ih < 0) { out.vertices.resize(out.vertices.size() - (il >= 0 ? 1 : 0) - (ih >= 0 ? 1 : 0)); ch.lo[k] = ch.hi[k] = int(out.vertices.size()); out.vertices.push_back(ch.pts[k]); continue; } ch.lo[k] = il; ch.hi[k] = ih; } // Room for the end crossings' copies along the edge: the nearer of the first interior // vertex's copies, measured along the contour, less a margin, over the edge's share of // that direction. if (m > 0) for (auto [c, k, other] : { std::make_tuple(&p, size_t(0), q.p), std::make_tuple(&q, m - 1, p.p) }) { (void) other; Crossing &cr = const_cast(*c); const Vec3f d = ch.pts[k] - cr.p; const float dl = d.norm(); if (dl < 1e-9f) continue; const Vec3f dn = d / dl; const float u_lo = (out.vertices[size_t(ch.lo[k])] - cr.p).dot(dn); const float u_hi = (out.vertices[size_t(ch.hi[k])] - cr.p).dot(dn); const Vec3f ed = (pos[size_t(cr.b)] - pos[size_t(cr.a)]).normalized(); cr.dt_max = std::min(cr.dt_max, 0.9f * std::max(std::min(u_lo, u_hi), 0.f) / std::max(std::abs(ed.dot(dn)), 1e-3f)); } } } for (auto &kv : edges) { Edge &edge = kv.second; for (size_t i = 0; i < edge.xs.size(); ++i) { Crossing &c = edge.xs[i]; if (!(c.sharp && c.used) || c.no_seam) { c.single = int(out.vertices.size()); out.vertices.push_back(c.p); continue; } const float len = (pos[size_t(c.b)] - pos[size_t(c.a)]).norm(); // Room along the edge: a third of the way to the previous and the next crossing or vertex, // and less than half the shortest contour segment leaving the crossing, so the copies of // the contour's next vertex cannot cross these. const float t_prev = (i == 0) ? 0.f : edge.xs[i - 1].t; const float t_next = (i + 1 == edge.xs.size()) ? 1.f : edge.xs[i + 1].t; float dt = half_gap / std::max(c.sin_min, 0.15f) / std::max(len, 1e-6f); dt = std::min({ dt, (c.t - t_prev) / 3.f, (t_next - c.t) / 3.f, c.dt_max / std::max(len, 1e-6f) }); if (!(dt > 1e-6f)) { c.single = int(out.vertices.size()); out.vertices.push_back(c.p); continue; } // The side of the edge before this crossing: the lower vertex's, flipped once per crossing // passed on the way. const bool before_high = side_of(vh[size_t(c.a)]) != (i % 2 == 1); // Each copy has to sample a pure value of its own side - inside the step's blend it would be // displaced to a height between the sides, and one such vertex tilts every triangle at it. // The copy is placed at the first of a few positions along the edge, from dt outward, that // samples pure; when none does the crossing stays one vertex and the surface ramps across // that edge only. // The search never goes past what the neighbours allow: less than half way to the next // crossing along the edge (so two crossings' copies never meet), most of the way to the // edge's vertex, and not past dt_max toward the contour's next vertex. const float lo_share = (i == 0) ? 0.9f : 0.49f, hi_share = (i + 1 == edge.xs.size()) ? 0.9f : 0.49f; const float t_lo_lim = c.t - std::min(lo_share * (c.t - t_prev), c.dt_max / std::max(len, 1e-6f)); const float t_hi_lim = c.t + std::min(hi_share * (t_next - c.t), c.dt_max / std::max(len, 1e-6f)); const auto place = [&](float sgn, bool high) -> int { for (int step = 1; step <= 6; ++step) { const float tt = c.t + sgn * dt * (0.5f * float(step)); if (tt < t_lo_lim || tt > t_hi_lim) break; const auto [q, qn] = sample_between(c.a, c.b, tt); const float h = sampler(q, qn) - iso; if ((h > 0.f) == high && std::abs(h) >= 0.3f * range) { out.vertices.push_back(q); return int(out.vertices.size()) - 1; } } return -1; }; const int na = place(-1.f, before_high), nb = place(+1.f, !before_high); if (na < 0 || nb < 0) { out.vertices.resize(out.vertices.size() - (na >= 0 ? 1 : 0) - (nb >= 0 ? 1 : 0)); c.single = int(out.vertices.size()); out.vertices.push_back(c.p); continue; } c.near_a = na; c.near_b = nb; c.lo = before_high ? c.near_b : c.near_a; c.hi = before_high ? c.near_a : c.near_b; } } // 6. Output triangles: each region of a cut triangle ear-clipped on its own side's copies, a wall // strip per contour wound with the surface, everything else passed through or plainly split. out.indices.reserve(nt * 2); std::vector source; source.reserve(nt * 2); const auto emit = [&](int a, int b, int c, int src) { out.indices.emplace_back(a, b, c); source.push_back(src); }; const auto copy_for = [&](const Crossing &c, bool high) { return c.single >= 0 ? c.single : (high ? c.hi : c.lo); }; // Triangulates a triangle that carries extra vertices on its edges without a zero-area sliver: the // first extra vertex found is joined to the opposite corner, which splits the triangle in two that // carry fewer extras each, and so on. A fan from a corner cannot do this - the extras on that // corner's own edges are collinear with it. Each side is the corner followed by the extras on the // way to the next corner. const std::function, 3>, int)> split_tri = [&](std::array, 3> side, int src) { for (int e = 0; e < 3; ++e) if (side[size_t(e)].size() > 1) { const auto &S = side[size_t(e)]; const int a = S[0], x = S[1], c = side[size_t((e + 2) % 3)][0]; std::vector rest(S.begin() + 1, S.end()); // x and the extras after it split_tri({ std::vector{ a }, std::vector{ x }, side[size_t((e + 2) % 3)] }, src); split_tri({ std::move(rest), side[size_t((e + 1) % 3)], std::vector{ c } }, src); return; } emit(side[0][0], side[1][0], side[2][0], src); }; // A wall strip between the low and high copies of a contour, from crossing p to crossing q. const auto emit_wall = [&](const Crossing &p, const Chain &ch, const Crossing &q, const Vec3f &N, int src) { std::vector lo, hi; lo.push_back(copy_for(p, false)); hi.push_back(copy_for(p, true)); for (size_t k = 0; k < ch.pts.size(); ++k) { lo.push_back(ch.lo[k]); hi.push_back(ch.hi[k]); } lo.push_back(copy_for(q, false)); hi.push_back(copy_for(q, true)); auto tri_up = [&](int i, int j, int k) { if (i == j || j == k || k == i) return; const Vec3f &A = out.vertices[size_t(i)], &B = out.vertices[size_t(j)], &C = out.vertices[size_t(k)]; if ((B - A).cross(C - A).dot(N) >= 0.f) emit(i, j, k, src); else emit(i, k, j, src); }; for (size_t k = 0; k + 1 < lo.size(); ++k) { tri_up(lo[k], lo[k + 1], hi[k + 1]); tri_up(lo[k], hi[k + 1], hi[k]); } }; for (size_t t = 0; t < nt; ++t) { const auto &tri = mesh.indices[t]; const Loop &L = loops[t]; const int n = int(L.xs.size()); if (n == 0) { emit(tri[0], tri[1], tri[2], int(t)); continue; } const size_t m = L.ids.size(); const bool s0 = side_of(vh[size_t(tri[0])]); if (L.ok) { // Every crossing starts one region: the run of the loop after it, on the side the field has // there, following any other contour it meets back to the loop, until it returns to the // start. Each region is tracked per (crossing, side) so the one between two contours is // emitted once, not once per contour. The region's vertices are collected in the triangle's // plane for ear clipping, since a traced contour can make it concave. const Vec3f &A = pos[size_t(tri[0])], &B = pos[size_t(tri[1])]; const Vec3f U = (B - A).normalized(), V = L.N.cross(U); std::vector pts2; std::vector ids2; const auto push = [&](int id) { const Vec3f &p = out.vertices[size_t(id)]; pts2.emplace_back((p - A).dot(U), (p - A).dot(V)); ids2.push_back(id); }; // Interior vertices of chain c, walking from crossing `from`, on side S. const auto push_chain = [&](int c, int from, bool S) { const Chain &ch = L.chains[size_t(c)]; const auto &cp = S ? ch.hi : ch.lo; if (from == ch.i) for (size_t k = 0; k < cp.size(); ++k) push(cp[k]); else for (size_t k = cp.size(); k-- > 0;) push(cp[k]); }; const auto side_after = [&](int k) { return (k % 2 == 0) ? !s0 : s0; }; std::vector> consumed(static_cast(n), { false, false }); for (int k = 0; k < n; ++k) { const bool S = side_after(k); if (consumed[size_t(k)][S]) continue; pts2.clear(); ids2.clear(); push(copy_for(*L.xs[size_t(k)], S)); consumed[size_t(k)][S] = true; size_t i = size_t(L.xpos[size_t(k)] + 1) % m; for (size_t guard = 0; guard < 2 * m; ++guard) { const int id = L.ids[i]; if (id >= 0) { push(id); i = (i + 1) % m; continue; } const int c = -id - 1; consumed[size_t(c)][S] = true; push(copy_for(*L.xs[size_t(c)], S)); if (c == L.partner[size_t(k)]) break; // Follow the contour from c to its partner, then continue the loop after it. const int pc = L.partner[size_t(c)]; push_chain(L.chain_of[size_t(c)], c, S); consumed[size_t(pc)][S] = true; push(copy_for(*L.xs[size_t(pc)], S)); if (pc == L.partner[size_t(k)]) break; i = size_t(L.xpos[size_t(pc)] + 1) % m; } // Close the polygon along the region's own contour back to k, in reverse. push_chain(L.chain_of[size_t(k)], L.partner[size_t(k)], S); std::vector poly(ids2.size()); std::iota(poly.begin(), poly.end(), 0); ear_clip(pts2, poly, [&](int a, int b, int c) { emit(ids2[size_t(a)], ids2[size_t(b)], ids2[size_t(c)], int(t)); }); } for (const auto &ch : L.chains) emit_wall(*L.xs[size_t(ch.i)], ch, *L.xs[size_t(ch.j)], L.N, int(t)); continue; } // Not cut: split at the crossings so the neighbours' cuts meet no T-junction. A doubled crossing // contributes both copies, the one nearer the vertex the walk comes from first. std::array, 3> side; int e = -1; for (size_t i = 0; i < m; ++i) { if (L.ids[i] >= 0) { ++e; side[size_t(e)].push_back(L.ids[i]); continue; } const Crossing &c = *L.xs[size_t(-L.ids[i] - 1)]; if (c.single >= 0) { side[size_t(e)].push_back(c.single); continue; } const bool from_a = tri[e] == c.a; side[size_t(e)].push_back(from_a ? c.near_a : c.near_b); side[size_t(e)].push_back(from_a ? c.near_b : c.near_a); } split_tri(std::move(side), int(t)); } // Safety net: a texture whose features are of the seam's own scale can fold copies over each // other in spite of everything above. When more than a trace of the output faces the wrong way, // the cut is not trusted and the mesh goes out as it came in. { size_t inverted = 0; for (size_t i = 0; i < out.indices.size(); ++i) { const auto &f = out.indices[i]; const auto &g = mesh.indices[size_t(source[i])]; const Vec3f n = (out.vertices[size_t(f[1])] - out.vertices[size_t(f[0])]).cross(out.vertices[size_t(f[2])] - out.vertices[size_t(f[0])]); const Vec3f ns = (mesh.vertices[size_t(g[1])] - mesh.vertices[size_t(g[0])]).cross(mesh.vertices[size_t(g[2])] - mesh.vertices[size_t(g[0])]); inverted += n.dot(ns) < 0.f && n.norm() > 1e-7f; } if (double(inverted) > 0.002 * double(out.indices.size())) return passthrough(); } if (out_source) *out_source = std::move(source); if (out_cut_count) *out_cut_count = cut_count; return out; } } // namespace Slic3r