From 0308d263b8059e27bde7657d1c46377493ce34c4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 24 Aug 2026 22:49:25 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(#550):=20Slice=20G=20part=201=20?= =?UTF-8?q?=E2=80=94=20cavity/curvature/AO=20generators=20+=20cache=20(pur?= =?UTF-8?q?e=20data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of Paint v2 Slice G: the Ogre-free cores that compute the derived maps and cache them. Controller/brush/mask/recipe wiring follows. **DerivedMapGenerator** — per-vertex concavity from the HalfEdgeMesh 1-ring (average of the vertex normal dotted with the direction to each neighbour: positive = concave crevice, negative = convex ridge), remapped per kind (cavity keeps only the concave half; curvature is signed around 0.5 with a flat tolerance so tessellation noise on flat panels does not speckle), then rasterised into UV0 with seam dilation. Rasterisation is a local float implementation rather than a call into VertexColorBaker: that one is typed on RGBA8 ColourValue, so a scalar map would quantise to 8 bits and band visibly across a smooth AO gradient. The coverage- vector + double-buffered dilate discipline is copied from it deliberately — including why coverage must be explicit rather than inferred from "differs from background". AmbientOcclusion is deliberately NOT handled by generate(): it needs scene-side visibility, so generate() refuses it with a message pointing at fromVertexOcclusion(). That keeps the whole rasterisation path headless. **DerivedMapCache** — versioned /paint/derived_maps//.bin, following HdrCache's magic+version header and its 40-hex-char key validation (which makes "../" structurally unrepresentable rather than sanitised), plus temp-file-then-rename so an interrupted save cannot leave a half-written entry that a later load would trust. Invalidation is by CONTENT HASH, not the "EditableMesh revision counter" the issue proposed: no such counter exists, and EditableMesh exposes a public mutable subMeshes() accessor, so any counter could be bypassed without incrementing. The SHA-1 already needed for the directory name IS the invalidation. It covers positions/normals/UV/indices and deliberately EXCLUDES vertex colour and bone weights, which cannot change these maps — including them would cause needless rebakes. **DerivedMapOcclusion** — AO via depth-map visibility (the approach ProjectionPainter::OcclusionMap already proves) instead of CPU rays: there is no BVH/kd-tree in the repo and every existing ray query is a brute-force linear scan, so this avoids adding an acceleration structure. The visibility MATHS is pure data (vertex + DepthViews -> scalar) and unit-tested against synthetic depth images; only the rendering of those views will touch the Ogre scene. Two non-obvious behaviours, both pinned by tests: - Back-facing views are SKIPPED, not counted as occluding. Counting them would darken every vertex by ~half uniformly regardless of geometry. - When no view faces the normal the result is 0 (unoccluded), not 1 — the latter would black out the map on a mesh the view set happens not to cover. A behind-eye-plane guard was added after a test caught it: the perspective `behind` (w <= 0) flag never fires under an ORTHOGRAPHIC viewProj (w stays 1), so a point behind the camera got a negative axis distance that sailed through the `<= dMap + bias` comparison and read as visible. Depth-map views here are auto-framed ortho-ish renders, so that was the case that actually mattered. Tests: 37/37 (13 generator + 9 cache + 15 occlusion). Co-Authored-By: Claude Opus 5 (1M context) --- src/CMakeLists.txt | 6 + src/DerivedMapCache.cpp | 226 ++++++++++++++++++++++ src/DerivedMapCache.h | 60 ++++++ src/DerivedMapCache_test.cpp | 184 ++++++++++++++++++ src/DerivedMapGenerator.cpp | 313 +++++++++++++++++++++++++++++++ src/DerivedMapGenerator.h | 138 ++++++++++++++ src/DerivedMapGenerator_test.cpp | 280 +++++++++++++++++++++++++++ src/DerivedMapOcclusion.cpp | 112 +++++++++++ src/DerivedMapOcclusion.h | 84 +++++++++ src/DerivedMapOcclusion_test.cpp | 196 +++++++++++++++++++ 10 files changed, 1599 insertions(+) create mode 100644 src/DerivedMapCache.cpp create mode 100644 src/DerivedMapCache.h create mode 100644 src/DerivedMapCache_test.cpp create mode 100644 src/DerivedMapGenerator.cpp create mode 100644 src/DerivedMapGenerator.h create mode 100644 src/DerivedMapGenerator_test.cpp create mode 100644 src/DerivedMapOcclusion.cpp create mode 100644 src/DerivedMapOcclusion.h create mode 100644 src/DerivedMapOcclusion_test.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b0f5867e..1a942d99 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -164,6 +164,9 @@ BrushAssetLibrary.cpp TexturePaintBuffer.cpp TexturePaintController.cpp VertexColorBaker.cpp +DerivedMapGenerator.cpp +DerivedMapCache.cpp +DerivedMapOcclusion.cpp VATBaker.cpp VATBakerController.cpp VATShaderEmitter.cpp @@ -395,6 +398,9 @@ TexturePaintBuffer.h UpdateVersion.h TexturePaintController.h VertexColorBaker.h +DerivedMapGenerator.h +DerivedMapCache.h +DerivedMapOcclusion.h ApplyAtlas.h EmbeddedTextureCache.h NormalMapGenerator.h diff --git a/src/DerivedMapCache.cpp b/src/DerivedMapCache.cpp new file mode 100644 index 00000000..f7f1c108 --- /dev/null +++ b/src/DerivedMapCache.cpp @@ -0,0 +1,226 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — on-disk cache for cavity / curvature / AO maps +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapCache.h" + +#include "EditableMesh.h" + +#include +#include +#include +#include +#include + +namespace { + +#pragma pack(push, 1) +struct FileHeader { + uint32_t magic = 0; + uint32_t version = 0; + int32_t width = 0; + int32_t height = 0; + uint32_t valueBytes = 0; ///< width*height*sizeof(float) + uint32_t coverageBytes = 0; ///< width*height*sizeof(uint8_t) +}; +#pragma pack(pop) + +/// A cache key becomes a path component, so validate it structurally: exactly +/// 40 lowercase-or-upper hex chars (SHA-1 hex length). This makes "../" and +/// absolute paths unrepresentable rather than relying on sanitisation. +bool isValidKey(const QString& key) +{ + if (key.size() != 40) return false; + for (const QChar c : key) { + const char ch = c.toLatin1(); + const bool hex = (ch >= '0' && ch <= '9') + || (ch >= 'a' && ch <= 'f') + || (ch >= 'A' && ch <= 'F'); + if (!hex) return false; + } + return true; +} + +QString entryFile(const QString& meshHash, DerivedMapKind kind) +{ + const QString dir = DerivedMapCache::entryDirectory(meshHash); + if (dir.isEmpty()) return {}; + return QDir(dir).filePath( + QString::fromLatin1(DerivedMapGenerator::kindName(kind)) + QStringLiteral(".bin")); +} + +} // namespace + +namespace DerivedMapCache { + +QString cacheRootDirectory() +{ + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (base.isEmpty()) return {}; // guard AppData being unavailable + return QDir(base).filePath(QStringLiteral("paint/derived_maps")); +} + +QString entryDirectory(const QString& meshHash) +{ + if (!isValidKey(meshHash)) return {}; + const QString root = cacheRootDirectory(); + if (root.isEmpty()) return {}; + return QDir(root).filePath(meshHash); +} + +QString meshHash(const EditableMesh& mesh) +{ + QCryptographicHash hash(QCryptographicHash::Sha1); + // Mix in the format version so an algorithm change cannot collide with a + // stale entry that happens to share geometry. + const uint32_t ver = kFormatVersion; + hash.addData(QByteArrayView(reinterpret_cast(&ver), sizeof(ver))); + + for (const EditableSubMesh& sm : mesh.subMeshes()) { + const uint32_t vCount = static_cast(sm.vertices.size()); + const uint32_t tCount = static_cast(sm.triangles.size()); + hash.addData(QByteArrayView(reinterpret_cast(&vCount), sizeof(vCount))); + hash.addData(QByteArrayView(reinterpret_cast(&tCount), sizeof(tCount))); + for (const EditableVertex& v : sm.vertices) { + // Only geometry that can change cavity/curvature/AO. Colour and + // bone weights are excluded on purpose (they cannot). + const float buf[8] = { + v.position.x, v.position.y, v.position.z, + v.normal.x, v.normal.y, v.normal.z, + v.uv.x, v.uv.y, + }; + hash.addData(QByteArrayView(reinterpret_cast(buf), sizeof(buf))); + } + for (const EditableTriangle& t : sm.triangles) { + hash.addData(QByteArrayView(reinterpret_cast(t.indices), + sizeof(t.indices))); + } + } + return QString::fromLatin1(hash.result().toHex()); +} + +bool has(const QString& meshHash, DerivedMapKind kind) +{ + const QString path = entryFile(meshHash, kind); + return !path.isEmpty() && QFileInfo::exists(path); +} + +bool load(const QString& meshHash, DerivedMapKind kind, DerivedMap& out, QString& error) +{ + const QString path = entryFile(meshHash, kind); + if (path.isEmpty()) { error = QStringLiteral("invalid cache key"); return false; } + + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { error = QStringLiteral("cache miss"); return false; } + + FileHeader hdr; + if (f.read(reinterpret_cast(&hdr), sizeof(hdr)) != qint64(sizeof(hdr))) { + error = QStringLiteral("truncated header"); + return false; + } + // Any structural mismatch is a miss, not an error to surface: a stale entry + // from an older format must simply be regenerated. + if (hdr.magic != kMagic || hdr.version != kFormatVersion) { + error = QStringLiteral("format mismatch"); + return false; + } + if (hdr.width <= 0 || hdr.height <= 0) { error = QStringLiteral("bad dimensions"); return false; } + + const size_t n = static_cast(hdr.width) * static_cast(hdr.height); + if (hdr.valueBytes != n * sizeof(float) || hdr.coverageBytes != n) { + error = QStringLiteral("payload size mismatch"); + return false; + } + + DerivedMap m; + m.width = hdr.width; + m.height = hdr.height; + m.values.resize(n); + m.coverage.resize(n); + if (f.read(reinterpret_cast(m.values.data()), hdr.valueBytes) != qint64(hdr.valueBytes) + || f.read(reinterpret_cast(m.coverage.data()), hdr.coverageBytes) != qint64(hdr.coverageBytes)) { + error = QStringLiteral("truncated payload"); + return false; + } + out = std::move(m); + error.clear(); + return true; +} + +bool save(const QString& meshHash, DerivedMapKind kind, const DerivedMap& in, QString& error) +{ + if (in.empty()) { error = QStringLiteral("refusing to cache an empty map"); return false; } + const QString path = entryFile(meshHash, kind); + if (path.isEmpty()) { error = QStringLiteral("invalid cache key"); return false; } + + const size_t n = static_cast(in.width) * static_cast(in.height); + if (in.values.size() != n || in.coverage.size() != n) { + error = QStringLiteral("map arrays do not match its dimensions"); + return false; + } + + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + error = QStringLiteral("could not create cache directory"); + return false; + } + + // Write to a temp file and rename, so an interrupted save can never leave a + // half-written entry that a later load would treat as valid. + const QString tmpPath = path + QStringLiteral(".tmp"); + { + QFile f(tmpPath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + error = QStringLiteral("could not open cache file for writing"); + return false; + } + FileHeader hdr; + hdr.magic = kMagic; + hdr.version = kFormatVersion; + hdr.width = in.width; + hdr.height = in.height; + hdr.valueBytes = static_cast(n * sizeof(float)); + hdr.coverageBytes = static_cast(n); + const auto bad = [&](const char* what) { + error = QStringLiteral("short write (%1)").arg(QLatin1String(what)); + f.close(); + QFile::remove(tmpPath); + return false; + }; + if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != qint64(sizeof(hdr))) + return bad("header"); + if (f.write(reinterpret_cast(in.values.data()), hdr.valueBytes) + != qint64(hdr.valueBytes)) + return bad("values"); + if (f.write(reinterpret_cast(in.coverage.data()), hdr.coverageBytes) + != qint64(hdr.coverageBytes)) + return bad("coverage"); + f.close(); + } + QFile::remove(path); // rename() will not overwrite on all platforms + if (!QFile::rename(tmpPath, path)) { + QFile::remove(tmpPath); + error = QStringLiteral("could not finalise cache file"); + return false; + } + error.clear(); + return true; +} + +void invalidate(const QString& meshHash, DerivedMapKind kind) +{ + const QString path = entryFile(meshHash, kind); + if (!path.isEmpty()) QFile::remove(path); +} + +void invalidateAll(const QString& meshHash) +{ + const QString dir = entryDirectory(meshHash); + if (!dir.isEmpty()) QDir(dir).removeRecursively(); +} + +} // namespace DerivedMapCache diff --git a/src/DerivedMapCache.h b/src/DerivedMapCache.h new file mode 100644 index 00000000..62ac4492 --- /dev/null +++ b/src/DerivedMapCache.h @@ -0,0 +1,60 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — on-disk cache for cavity / curvature / AO maps +(Paint v2 Slice G, issue #550) + +Versioned cache under /paint/derived_maps//.bin, so a +map is generated once per mesh rather than on every brush stroke. + +Invalidation is by CONTENT HASH, not a revision counter: the issue proposed an +"EditableMesh revision counter", but EditableMesh has no such counter and +exposes a public mutable subMeshes() accessor, so any counter could be bypassed +without incrementing. Hashing the geometry we already have to hash for the +directory name makes changed geometry a natural cache miss, with nothing to keep +in sync. See meshHash(). + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPCACHE_H +#define DERIVEDMAPCACHE_H + +#include "DerivedMapGenerator.h" + +#include + +#include + +class EditableMesh; + +namespace DerivedMapCache { + +constexpr uint32_t kMagic = 0x444D5031u; // 'DMP1' +/// Bump to invalidate every cached entry at once — do this whenever a +/// generator's output would change for identical input (algorithm tweak, +/// different remap curve, etc.), since the mesh hash alone would not notice. +constexpr uint32_t kFormatVersion = 1; + +/// /paint/derived_maps. Empty if AppData is unavailable. +QString cacheRootDirectory(); +/// /paint/derived_maps/. Empty if the key is malformed. +QString entryDirectory(const QString& meshHash); + +/// SHA-1 (40 hex chars) over the geometry that affects a derived map: +/// per-submesh vertex positions, normals, UV0 and triangle indices. Vertex +/// colours / bone weights are deliberately excluded — they cannot change +/// cavity, curvature or AO, so including them would cause needless rebakes. +QString meshHash(const EditableMesh& mesh); + +bool has(const QString& meshHash, DerivedMapKind kind); +bool load(const QString& meshHash, DerivedMapKind kind, DerivedMap& out, QString& error); +bool save(const QString& meshHash, DerivedMapKind kind, const DerivedMap& in, QString& error); + +/// Remove one kind, or the whole mesh entry ("Recalculate derived maps"). +void invalidate(const QString& meshHash, DerivedMapKind kind); +void invalidateAll(const QString& meshHash); + +} // namespace DerivedMapCache + +#endif // DERIVEDMAPCACHE_H diff --git a/src/DerivedMapCache_test.cpp b/src/DerivedMapCache_test.cpp new file mode 100644 index 00000000..85ba596c --- /dev/null +++ b/src/DerivedMapCache_test.cpp @@ -0,0 +1,184 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapCache unit tests (Paint v2 Slice G, issue #550) + +Exercises the round-trip, the content-hash invalidation the issue's proposed +"revision counter" was replaced by, and the malformed-key path guard. +Writes under the test process's own AppData location; no Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapCache.h" +#include "EditableMesh.h" + +#include +#include + +#include +#include + +namespace { + +EditableMesh triMesh(float z = 0.0f) +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + auto push = [&](float x, float y, float u, float v) { + EditableVertex ev; + ev.position = Ogre::Vector3(x, y, z); + ev.normal = Ogre::Vector3(0, 0, 1); ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + push(0, 0, 0, 0); push(1, 0, 1, 0); push(0, 1, 0, 1); + sm.triangles.push_back({0, 1, 2}); + return m; +} + +DerivedMap makeMap(int w, int h, float fill) +{ + DerivedMap m; + m.width = w; m.height = h; + m.values.assign(static_cast(w) * h, fill); + m.coverage.assign(static_cast(w) * h, 1u); + return m; +} + +} // namespace + +TEST(DerivedMapCacheTest, MeshHashIsStableAndGeometrySensitive) { + const QString a = DerivedMapCache::meshHash(triMesh(0.0f)); + const QString b = DerivedMapCache::meshHash(triMesh(0.0f)); + const QString c = DerivedMapCache::meshHash(triMesh(5.0f)); + + EXPECT_EQ(a.size(), 40) << "must be SHA-1 hex so the path guard accepts it"; + EXPECT_EQ(a, b) << "same geometry must hash identically (else every load misses)"; + EXPECT_NE(a, c) << "moved geometry must hash differently — this IS the invalidation"; +} + +TEST(DerivedMapCacheTest, HashIgnoresNonGeometricAttributes) { + // Vertex colour cannot change cavity/curvature/AO, so it must not force a + // rebake. If this ever starts failing, painting a vertex colour would + // silently invalidate every derived map for that mesh. + EditableMesh m1 = triMesh(); + EditableMesh m2 = triMesh(); + m2.subMeshes()[0].vertices[0].color = Ogre::ColourValue(1, 0, 0, 1); + m2.subMeshes()[0].vertices[0].hasColor = true; + EXPECT_EQ(DerivedMapCache::meshHash(m1), DerivedMapCache::meshHash(m2)); +} + +TEST(DerivedMapCacheTest, HashChangesWithUvAndNormals) { + // UV and normals DO affect the output (UV decides where texels land, + // normals decide the concavity sign), so both must be in the hash. + EditableMesh uvChanged = triMesh(); + uvChanged.subMeshes()[0].vertices[1].uv = Ogre::Vector2(0.5f, 0.5f); + EXPECT_NE(DerivedMapCache::meshHash(triMesh()), DerivedMapCache::meshHash(uvChanged)); + + EditableMesh nChanged = triMesh(); + nChanged.subMeshes()[0].vertices[1].normal = Ogre::Vector3(1, 0, 0); + EXPECT_NE(DerivedMapCache::meshHash(triMesh()), DerivedMapCache::meshHash(nChanged)); +} + +TEST(DerivedMapCacheTest, SaveLoadRoundTripsExactly) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + + const DerivedMap in = makeMap(8, 4, 0.375f); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, in, err)) << err.toStdString(); + EXPECT_TRUE(DerivedMapCache::has(key, DerivedMapKind::Cavity)); + + DerivedMap out; + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Cavity, out, err)) << err.toStdString(); + EXPECT_EQ(out.width, in.width); + EXPECT_EQ(out.height, in.height); + ASSERT_EQ(out.values.size(), in.values.size()); + for (size_t i = 0; i < in.values.size(); ++i) EXPECT_FLOAT_EQ(out.values[i], in.values[i]); + EXPECT_EQ(out.coverage, in.coverage); + + DerivedMapCache::invalidateAll(key); +} + +TEST(DerivedMapCacheTest, KindsAreStoredSeparately) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, makeMap(4, 4, 0.1f), err)); + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Curvature, makeMap(4, 4, 0.9f), err)); + + DerivedMap cav, curv; + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Cavity, cav, err)); + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Curvature, curv, err)); + EXPECT_NEAR(cav.values[0], 0.1f, 1e-6f); + EXPECT_NEAR(curv.values[0], 0.9f, 1e-6f) << "one kind must not overwrite another"; + + // Per-kind invalidation must not take the sibling with it. + DerivedMapCache::invalidate(key, DerivedMapKind::Cavity); + EXPECT_FALSE(DerivedMapCache::has(key, DerivedMapKind::Cavity)); + EXPECT_TRUE(DerivedMapCache::has(key, DerivedMapKind::Curvature)); + + DerivedMapCache::invalidateAll(key); + EXPECT_FALSE(DerivedMapCache::has(key, DerivedMapKind::Curvature)); +} + +TEST(DerivedMapCacheTest, MalformedKeysAreRejectedAsPaths) { + // The key becomes a path component, so traversal attempts and wrong-length + // keys must be structurally impossible rather than sanitised. + for (const char* bad : {"../../etc/passwd", "not-hex-at-all", "", "abc", + "/absolute/path", "0123456789012345678901234567890123456789z"}) { + const QString k = QString::fromLatin1(bad); + EXPECT_TRUE(DerivedMapCache::entryDirectory(k).isEmpty()) << bad; + EXPECT_FALSE(DerivedMapCache::has(k, DerivedMapKind::Cavity)) << bad; + QString err; + EXPECT_FALSE(DerivedMapCache::save(k, DerivedMapKind::Cavity, makeMap(2, 2, 0.5f), err)) << bad; + DerivedMap out; + EXPECT_FALSE(DerivedMapCache::load(k, DerivedMapKind::Cavity, out, err)) << bad; + } + // A well-formed 40-char hex key IS accepted. + EXPECT_FALSE(DerivedMapCache::entryDirectory( + QStringLiteral("0123456789abcdef0123456789abcdef01234567")).isEmpty()); +} + +TEST(DerivedMapCacheTest, RefusesToCacheEmptyMap) { + const QString key = DerivedMapCache::meshHash(triMesh()); + QString err; + // An empty map means the generator failed; caching it would poison every + // later load with a valid-looking "no signal" result. + EXPECT_FALSE(DerivedMapCache::save(key, DerivedMapKind::Cavity, DerivedMap{}, err)); + EXPECT_FALSE(err.isEmpty()); +} + +TEST(DerivedMapCacheTest, CorruptEntryIsAMissNotACrash) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, makeMap(4, 4, 0.5f), err)); + + // Truncate the payload behind the cache's back. + const QString path = QDir(DerivedMapCache::entryDirectory(key)).filePath(QStringLiteral("cavity.bin")); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::ReadWrite)); + ASSERT_TRUE(f.resize(12)); // header only, no payload + } + DerivedMap out; + EXPECT_FALSE(DerivedMapCache::load(key, DerivedMapKind::Cavity, out, err)); + EXPECT_FALSE(err.isEmpty()); + + DerivedMapCache::invalidateAll(key); +} + +TEST(DerivedMapCacheTest, LoadOfMissingEntryFailsCleanly) { + const QString key = QStringLiteral("abcdefabcdefabcdefabcdefabcdefabcdefabcd"); + DerivedMapCache::invalidateAll(key); + DerivedMap out; + QString err; + EXPECT_FALSE(DerivedMapCache::load(key, DerivedMapKind::AmbientOcclusion, out, err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(out.empty()); +} diff --git a/src/DerivedMapGenerator.cpp b/src/DerivedMapGenerator.cpp new file mode 100644 index 00000000..7382cb86 --- /dev/null +++ b/src/DerivedMapGenerator.cpp @@ -0,0 +1,313 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — cavity / curvature / AO derived-map generation +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapGenerator.h" + +#include "HalfEdgeMesh.h" + +#include +#include + +#include +#include + +namespace { + +// Edge-function half-space rasteriser over the UV triangle, matching +// VertexColorBaker::rasterizeTriangle's conventions exactly: UV -> pixel is +// (u*W, v*H) with a TOP-LEFT origin, and the winding is handled by flipping +// the edge weights when the signed area is negative (so the caller need not +// pre-sort). Kept local (rather than reusing VertexColorBaker) because that +// one is typed on RGBA8 ColourValue; a scalar map wants float precision and +// 1/4 the memory, and replicating a scalar into RGB then reading back .r +// would quantise to 8 bits — visible banding on a smooth AO gradient. +int rasteriseScalarTriangle(DerivedMap& map, + const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + float s0, float s1, float s2) +{ + const int W = map.width; + const int H = map.height; + if (W <= 0 || H <= 0) return 0; + + const float x0 = uv0.x * W, y0 = uv0.y * H; + const float x1 = uv1.x * W, y1 = uv1.y * H; + const float x2 = uv2.x * W, y2 = uv2.y * H; + + int minX = static_cast(std::floor(std::min({x0, x1, x2}))); + int maxX = static_cast(std::ceil (std::max({x0, x1, x2}))); + int minY = static_cast(std::floor(std::min({y0, y1, y2}))); + int maxY = static_cast(std::ceil (std::max({y0, y1, y2}))); + minX = std::max(0, minX); minY = std::max(0, minY); + maxX = std::min(W, maxX); maxY = std::min(H, maxY); + if (minX >= maxX || minY >= maxY) return 0; + + const float area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (std::abs(area) < 1e-12f) return 0; // degenerate in UV space + const float invArea = 1.0f / area; + + int written = 0; + for (int py = minY; py < maxY; ++py) { + for (int px = minX; px < maxX; ++px) { + const float cx = px + 0.5f, cy = py + 0.5f; + float w0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) * invArea; + float w1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) * invArea; + float w2 = 1.0f - w0 - w1; + const float eps = -1e-5f; + if (w0 < eps || w1 < eps || w2 < eps) continue; + const size_t idx = static_cast(py) * W + px; + map.values[idx] = w0 * s0 + w1 * s1 + w2 * s2; + map.coverage[idx] = 1; + ++written; + } + } + return written; +} + +// Smear covered texels outward by `iterations` pixels. Mirrors +// VertexColorBaker::dilate: double-buffered so a pass cannot cascade within +// itself, first-covered-neighbour wins, early-exit when a pass changes +// nothing. +int dilateScalar(DerivedMap& map, int iterations) +{ + const int W = map.width, H = map.height; + if (W <= 0 || H <= 0 || iterations <= 0) return 0; + + int totalFlipped = 0; + for (int it = 0; it < iterations; ++it) { + std::vector nextVals = map.values; + std::vector nextCov = map.coverage; + int flipped = 0; + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + const size_t idx = static_cast(y) * W + x; + if (map.coverage[idx]) continue; + bool found = false; + for (int dy = -1; dy <= 1 && !found; ++dy) { + for (int dx = -1; dx <= 1 && !found; ++dx) { + if (dx == 0 && dy == 0) continue; + const int nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const size_t nidx = static_cast(ny) * W + nx; + if (!map.coverage[nidx]) continue; + nextVals[idx] = map.values[nidx]; + nextCov[idx] = 1; + found = true; + ++flipped; + } + } + } + } + if (flipped == 0) break; + map.values.swap(nextVals); + map.coverage.swap(nextCov); + totalFlipped += flipped; + } + return totalFlipped; +} + +} // namespace + +float DerivedMap::sample(float u, float v) const +{ + if (empty()) return 0.0f; + int x = static_cast(u * width); + int y = static_cast(v * height); + x = std::clamp(x, 0, width - 1); + y = std::clamp(y, 0, height - 1); + return values[static_cast(y) * width + x]; +} + +const char* DerivedMapGenerator::kindName(DerivedMapKind kind) +{ + switch (kind) { + case DerivedMapKind::Cavity: return "cavity"; + case DerivedMapKind::Curvature: return "curvature"; + case DerivedMapKind::AmbientOcclusion: return "ao"; + } + return "unknown"; +} + +float DerivedMapGenerator::backgroundFor(DerivedMapKind kind) +{ + // "No effect" per kind: cavity/AO 0 = no dirt / no occlusion; + // curvature 0.5 = flat (it is a signed signal centred at 0.5). + switch (kind) { + case DerivedMapKind::Cavity: return 0.0f; + case DerivedMapKind::AmbientOcclusion: return 0.0f; + case DerivedMapKind::Curvature: return 0.5f; + } + return 0.0f; +} + +float DerivedMapGenerator::remapForKind(float concavity, DerivedMapKind kind, + const Options& options) +{ + const float c = std::clamp(concavity * options.contrast, -1.0f, 1.0f); + switch (kind) { + case DerivedMapKind::Cavity: + // Keep only the concave half: crevices matter, ridges do not. + return std::clamp(c, 0.0f, 1.0f); + case DerivedMapKind::Curvature: { + // Signed, centred at 0.5. Pin near-flat to neutral so tessellation + // noise on flat panels does not show up as speckled edge wear. + if (std::abs(c) < options.flatTolerance) return 0.5f; + return std::clamp(0.5f + 0.5f * c, 0.0f, 1.0f); + } + case DerivedMapKind::AmbientOcclusion: + return std::clamp(c, 0.0f, 1.0f); + } + return 0.0f; +} + +size_t DerivedMapGenerator::weldedVertexCount(const EditableMesh& mesh) +{ + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) return 0; + return he.vertexCount(); +} + +std::vector DerivedMapGenerator::vertexConcavity(const EditableMesh& mesh) +{ + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) return {}; + + const size_t n = he.vertexCount(); + std::vector out(n, 0.0f); + for (size_t i = 0; i < n; ++i) { + const int vi = static_cast(i); + const HEVertex& v = he.vertex(vi); + Ogre::Vector3 nrm = v.normal; + if (!v.hasNormal || nrm.isZeroLength()) { + // No usable normal: leave flat rather than inventing a signal. + out[i] = 0.0f; + continue; + } + nrm.normalise(); + + const std::vector ring = he.verticesAroundVertex(vi); + if (ring.empty()) { out[i] = 0.0f; continue; } + + // Average, over the 1-ring, of how far each neighbour sits along the + // vertex normal relative to its distance. Neighbours "above" the + // tangent plane (positive dot) mean the surface closes in around this + // vertex => concave. Below => convex ridge. + float acc = 0.0f; + int used = 0; + for (const int ni : ring) { + const Ogre::Vector3 d = he.vertex(ni).position - v.position; + const float len = d.length(); + if (len < 1e-9f) continue; // coincident vertex + acc += nrm.dotProduct(d / len); + ++used; + } + out[i] = (used > 0) ? (acc / static_cast(used)) : 0.0f; + } + return out; +} + +DerivedMap DerivedMapGenerator::rasterise(const EditableMesh& mesh, + const std::vector& perVertex, + const Options& options, + Report* report) +{ + DerivedMap map; + Report rep; + + const int res = std::max(1, options.resolution); + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) { + rep.error = QStringLiteral("could not build half-edge mesh"); + if (report) *report = rep; + return map; + } + if (perVertex.size() != he.vertexCount()) { + rep.error = QStringLiteral("per-vertex array size %1 != welded vertex count %2") + .arg(perVertex.size()).arg(he.vertexCount()); + if (report) *report = rep; + return map; + } + + map.width = res; + map.height = res; + map.values.assign(static_cast(res) * res, options.background); + map.coverage.assign(static_cast(res) * res, 0u); + + const size_t faceCount = he.faceCount(); + for (size_t f = 0; f < faceCount; ++f) { + const std::vector fv = he.faceVertices(static_cast(f)); + if (fv.size() != 3) continue; + const HEVertex& a = he.vertex(fv[0]); + const HEVertex& b = he.vertex(fv[1]); + const HEVertex& c = he.vertex(fv[2]); + if (!a.hasUV || !b.hasUV || !c.hasUV) { ++rep.trianglesSkippedNoUv; continue; } + rep.texelsRasterised += rasteriseScalarTriangle( + map, a.uv, b.uv, c.uv, + perVertex[fv[0]], perVertex[fv[1]], perVertex[fv[2]]); + } + + rep.texelsDilated = dilateScalar(map, options.dilationPixels); + + // Report the observed range over covered texels only — an all-background + // range would hide a generator that produced nothing. + bool any = false; + for (size_t i = 0; i < map.values.size(); ++i) { + if (!map.coverage[i]) continue; + const float x = map.values[i]; + if (!any) { rep.minValue = rep.maxValue = x; any = true; } + else { rep.minValue = std::min(rep.minValue, x); rep.maxValue = std::max(rep.maxValue, x); } + } + rep.ok = true; + if (report) *report = rep; + return map; +} + +DerivedMap DerivedMapGenerator::generate(const EditableMesh& mesh, + DerivedMapKind kind, + const Options& options, + Report* report) +{ + if (kind == DerivedMapKind::AmbientOcclusion) { + Report rep; + rep.error = QStringLiteral( + "AmbientOcclusion needs scene-side occlusion sampling; " + "use fromVertexOcclusion()"); + if (report) *report = rep; + return {}; + } + + const std::vector raw = vertexConcavity(mesh); + if (raw.empty()) { + Report rep; + rep.error = QStringLiteral("mesh has no vertices / could not be welded"); + if (report) *report = rep; + return {}; + } + std::vector mapped(raw.size()); + for (size_t i = 0; i < raw.size(); ++i) + mapped[i] = remapForKind(raw[i], kind, options); + + Options opts = options; + opts.background = backgroundFor(kind); + return rasterise(mesh, mapped, opts, report); +} + +DerivedMap DerivedMapGenerator::fromVertexOcclusion(const EditableMesh& mesh, + const std::vector& occlusion, + const Options& options, + Report* report) +{ + std::vector clamped(occlusion.size()); + for (size_t i = 0; i < occlusion.size(); ++i) + clamped[i] = std::clamp(occlusion[i], 0.0f, 1.0f); + + Options opts = options; + opts.background = backgroundFor(DerivedMapKind::AmbientOcclusion); + return rasterise(mesh, clamped, opts, report); +} diff --git a/src/DerivedMapGenerator.h b/src/DerivedMapGenerator.h new file mode 100644 index 00000000..fc5c0508 --- /dev/null +++ b/src/DerivedMapGenerator.h @@ -0,0 +1,138 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — cavity / curvature / AO derived-map generation +(Paint v2 Slice G, issue #550) + +Generates per-mesh scalar maps used as brush colour sources or layer masks: +edge wear from convex curvature, crevice dirt from cavity, weathering from AO. + +Pure data: inputs are an EditableMesh (+ optional pre-baked occlusion samples), +outputs are scalar maps in UV0 space, so the core is unit-testable headlessly. +The Ogre-scene side (depth-map rendering for AO, texture upload) lives in the +controller — see DerivedMapCache for the on-disk side. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPGENERATOR_H +#define DERIVEDMAPGENERATOR_H + +#include "EditableMesh.h" + +#include + +#include +#include + +/// Which derived map to generate. Values are persisted in the on-disk cache +/// (as a filename component), so do NOT renumber — append only. +enum class DerivedMapKind { + Cavity = 0, ///< concave = 1 (dark/dirt), convex+flat = 0 + Curvature = 1, ///< signed, remapped to 0..1 with 0.5 = flat + AmbientOcclusion = 2, +}; + +/// A single-channel float map in UV0 space, plus the coverage mask the +/// rasteriser produced (1 = a triangle wrote this texel, 0 = background). +/// Coverage is kept alongside the values because dilation must know which +/// texels are real — a texel whose value happens to equal the background is +/// otherwise indistinguishable from an unwritten one (the same reasoning as +/// VertexColorBaker's explicit coverage vector). +struct DerivedMap { + int width = 0; + int height = 0; + std::vector values; ///< size w*h, row-major, top-left origin + std::vector coverage; ///< size w*h, 1 = written by rasterisation + + bool empty() const { return width <= 0 || height <= 0 || values.empty(); } + /// Nearest-texel sample; clamps out-of-range UV to the edge. `v` follows + /// the project's top-left-origin convention (v=0 is the first row), the + /// same as TexturePaintBuffer::uvToPixel. + float sample(float u, float v) const; +}; + +class DerivedMapGenerator +{ +public: + struct Options { + /// Output map size (square). + int resolution = 1024; + /// Pixels of seam dilation after rasterisation (0 = none). UV islands + /// need this or bilinear/MIP sampling bleeds background across seams. + int dilationPixels = 4; + /// Value written where no triangle covers a texel. 0.5 is neutral for + /// curvature (flat) and for cavity/AO means "no occlusion" once the + /// caller's convention is applied; see kind-specific defaults in + /// backgroundFor(). + float background = 0.0f; + /// Cavity/curvature: scales the raw concavity before clamping. Higher + /// = more contrast. 1.0 leaves the geometric value untouched. + float contrast = 1.0f; + /// Curvature only: values within this of flat are pinned to neutral, + /// which suppresses tessellation noise on nominally flat panels. + float flatTolerance = 0.02f; + }; + + struct Report { + bool ok = false; + QString error; + int texelsRasterised = 0; + int texelsDilated = 0; + int trianglesSkippedNoUv = 0; + float minValue = 0.0f; + float maxValue = 0.0f; + }; + + /// Per-vertex concavity in -1..1: negative = convex (edge/ridge), + /// positive = concave (crevice), ~0 = flat. Computed from the angle + /// between the vertex normal and the direction to each 1-ring neighbour: + /// a neighbour lying "in front of" the normal plane means the surface + /// bends away (concave). Averaged over the ring. + /// + /// `mesh` is welded across submeshes internally (via HalfEdgeMesh) so a UV + /// seam or material split does not read as a crease. + /// Returns one value per WELDED vertex; use `weldedVertexCount()` to size. + static std::vector vertexConcavity(const EditableMesh& mesh); + + /// Rasterise per-welded-vertex scalars into a UV-space map. + /// `perVertex` must be sized as `vertexConcavity` returns. + static DerivedMap rasterise(const EditableMesh& mesh, + const std::vector& perVertex, + const Options& options, + Report* report = nullptr); + + /// Generate cavity or curvature. AmbientOcclusion is NOT handled here — + /// it needs scene-side occlusion sampling; use `fromVertexOcclusion`. + static DerivedMap generate(const EditableMesh& mesh, + DerivedMapKind kind, + const Options& options, + Report* report = nullptr); + + /// Build an AO map from per-welded-vertex occlusion in 0..1 (1 = fully + /// occluded). The caller supplies these from whatever visibility source it + /// has (the controller uses hemisphere depth maps); keeping that out of + /// here is what lets the rasterisation path stay headless-testable. + static DerivedMap fromVertexOcclusion(const EditableMesh& mesh, + const std::vector& occlusion, + const Options& options, + Report* report = nullptr); + + /// Number of welded vertices `vertexConcavity` will return / that + /// `perVertex` inputs must match. + static size_t weldedVertexCount(const EditableMesh& mesh); + + /// Map the -1..1 concavity signal to the 0..1 stored range for `kind`. + /// Cavity keeps only the concave half; curvature centres flat at 0.5. + /// Pure math, exposed for unit tests. + static float remapForKind(float concavity, DerivedMapKind kind, + const Options& options); + + /// Default background value for a kind (the value meaning "no effect"). + static float backgroundFor(DerivedMapKind kind); + + /// Stable short name, used as the on-disk filename component. + static const char* kindName(DerivedMapKind kind); +}; + +#endif // DERIVEDMAPGENERATOR_H diff --git a/src/DerivedMapGenerator_test.cpp b/src/DerivedMapGenerator_test.cpp new file mode 100644 index 00000000..d1cf302d --- /dev/null +++ b/src/DerivedMapGenerator_test.cpp @@ -0,0 +1,280 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapGenerator unit tests (Paint v2 Slice G, issue #550) + +Pure-data: builds synthetic EditableMeshes with KNOWN convex/concave geometry and +checks the cavity / curvature / AO signals land on the right sign and the right +UV texels. No Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapGenerator.h" + +#include +#include + +#include + +namespace { + +// A flat quad in the z=0 plane, normals +Z, UV filling 0..1. Baseline: every +// vertex must read as flat (concavity ~0). +EditableMesh flatQuad() +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + const Ogre::Vector3 n(0, 0, 1); + auto push = [&](float x, float y, float u, float v) { + EditableVertex ev; + ev.position = Ogre::Vector3(x, y, 0); + ev.normal = n; ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + push(-1, -1, 0, 1); push(1, -1, 1, 1); push(1, 1, 1, 0); push(-1, 1, 0, 0); + sm.triangles.push_back({0, 1, 2}); + sm.triangles.push_back({0, 2, 3}); + return m; +} + +// A "valley": two quads meeting along the y axis, folded so the shared centre +// edge is CONCAVE (a crevice). The centre verts' normals point up-and-inward, +// so their 1-ring neighbours sit above the tangent plane => positive concavity. +EditableMesh valley() +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + auto push = [&](const Ogre::Vector3& p, const Ogre::Vector3& n, float u, float v) { + EditableVertex ev; + ev.position = p; + ev.normal = n.normalisedCopy(); ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + // Left wall rises to x=-1, centre trough at x=0 (z=0), right wall rises. + const Ogre::Vector3 nUp(0, 0, 1); + push({-1, -1, 1}, {-1, 0, 1}, 0.0f, 1.0f); // 0 outer left, bottom + push({-1, 1, 1}, {-1, 0, 1}, 0.0f, 0.0f); // 1 outer left, top + push({ 0, -1, 0}, nUp, 0.5f, 1.0f); // 2 centre bottom (concave) + push({ 0, 1, 0}, nUp, 0.5f, 0.0f); // 3 centre top (concave) + push({ 1, -1, 1}, { 1, 0, 1}, 1.0f, 1.0f); // 4 outer right, bottom + push({ 1, 1, 1}, { 1, 0, 1}, 1.0f, 0.0f); // 5 outer right, top + sm.triangles.push_back({0, 2, 3}); + sm.triangles.push_back({0, 3, 1}); + sm.triangles.push_back({2, 4, 5}); + sm.triangles.push_back({2, 5, 3}); + return m; +} + +// A "ridge": the mirror of valley() — centre edge pushed UP so it is CONVEX. +EditableMesh ridge() +{ + EditableMesh m = valley(); + EditableSubMesh& sm = m.subMeshes()[0]; + // Flip the profile: outer verts high -> low, centre low -> high. + sm.vertices[0].position.z = 0.0f; + sm.vertices[1].position.z = 0.0f; + sm.vertices[2].position.z = 1.0f; + sm.vertices[3].position.z = 1.0f; + sm.vertices[4].position.z = 0.0f; + sm.vertices[5].position.z = 0.0f; + return m; +} + +} // namespace + +TEST(DerivedMapGeneratorTest, FlatSurfaceHasNoConcavity) { + const auto c = DerivedMapGenerator::vertexConcavity(flatQuad()); + ASSERT_FALSE(c.empty()); + for (const float v : c) EXPECT_NEAR(v, 0.0f, 1e-4f); +} + +TEST(DerivedMapGeneratorTest, ConcaveAndConvexHaveOppositeSign) { + // The centre verts are the folded edge in both fixtures. A valley must + // read positive (concave) there and a ridge negative (convex) — if these + // ever agree in sign, cavity and edge-wear would target the same texels. + const auto vc = DerivedMapGenerator::vertexConcavity(valley()); + const auto rc = DerivedMapGenerator::vertexConcavity(ridge()); + ASSERT_GE(vc.size(), 4u); + ASSERT_EQ(vc.size(), rc.size()); + + // Welding may reorder/merge, so find the extreme of each rather than + // assuming index 2/3 survive as-is. + const float vMax = *std::max_element(vc.begin(), vc.end()); + const float rMin = *std::min_element(rc.begin(), rc.end()); + EXPECT_GT(vMax, 0.05f) << "valley centre should read concave (positive)"; + EXPECT_LT(rMin, -0.05f) << "ridge centre should read convex (negative)"; +} + +TEST(DerivedMapGeneratorTest, CavityKeepsOnlyConcaveHalf) { + DerivedMapGenerator::Options o; + // Cavity discards ridges: a convex signal must clamp to 0, a concave one + // must survive. Otherwise crevice dirt would also land on every edge. + EXPECT_FLOAT_EQ(DerivedMapGenerator::remapForKind(-0.8f, DerivedMapKind::Cavity, o), 0.0f); + EXPECT_FLOAT_EQ(DerivedMapGenerator::remapForKind(0.0f, DerivedMapKind::Cavity, o), 0.0f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.6f, DerivedMapKind::Cavity, o), 0.6f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, CurvatureIsSignedAroundNeutralHalf) { + DerivedMapGenerator::Options o; + o.flatTolerance = 0.02f; + // Signed: concave above 0.5, convex below, flat pinned exactly to 0.5. + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.0f, DerivedMapKind::Curvature, o), 0.5f, 1e-6f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.01f, DerivedMapKind::Curvature, o), 0.5f, 1e-6f) + << "within flatTolerance must pin to neutral (suppresses tessellation noise)"; + EXPECT_GT(DerivedMapGenerator::remapForKind(0.8f, DerivedMapKind::Curvature, o), 0.5f); + EXPECT_LT(DerivedMapGenerator::remapForKind(-0.8f, DerivedMapKind::Curvature, o), 0.5f); +} + +TEST(DerivedMapGeneratorTest, ContrastScalesBeforeClamping) { + DerivedMapGenerator::Options o; + o.contrast = 4.0f; + // 0.2 * 4 = 0.8, and a large value must clamp rather than overflow. + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.2f, DerivedMapKind::Cavity, o), 0.8f, 1e-5f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.9f, DerivedMapKind::Cavity, o), 1.0f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, RasteriseFillsUvSpaceAndReportsRange) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + ASSERT_GT(n, 0u); + std::vector per(n, 0.75f); + + DerivedMapGenerator::Options o; + o.resolution = 32; + o.dilationPixels = 0; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::rasterise(m, per, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_EQ(map.width, 32); + EXPECT_EQ(map.height, 32); + EXPECT_GT(rep.texelsRasterised, 0); + // The quad's UV covers the whole unit square, so the centre must be + // covered and carry the constant value. + EXPECT_EQ(map.coverage[static_cast(16) * 32 + 16], 1); + EXPECT_NEAR(map.sample(0.5f, 0.5f), 0.75f, 1e-5f); + EXPECT_NEAR(rep.minValue, 0.75f, 1e-5f); + EXPECT_NEAR(rep.maxValue, 0.75f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, RasteriseRejectsWrongSizedInput) { + const EditableMesh m = flatQuad(); + DerivedMapGenerator::Options o; + o.resolution = 8; + DerivedMapGenerator::Report rep; + // A mismatched per-vertex array must be refused, not read out of bounds. + const DerivedMap map = DerivedMapGenerator::rasterise(m, {1.0f, 2.0f}, o, &rep); + EXPECT_FALSE(rep.ok); + EXPECT_FALSE(rep.error.isEmpty()); + EXPECT_TRUE(map.empty()); +} + +TEST(DerivedMapGeneratorTest, DilationExtendsBeyondCoverage) { + // A half-width UV quad leaves the right half of the map uncovered; with + // dilation on, texels just past the island edge must be filled (otherwise + // bilinear/MIP sampling bleeds background across the seam). + EditableMesh m = flatQuad(); + for (auto& v : m.subMeshes()[0].vertices) v.uv.x *= 0.5f; + + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + std::vector per(n, 1.0f); + + DerivedMapGenerator::Options o; + o.resolution = 32; + o.dilationPixels = 0; + DerivedMapGenerator::Report noDil; + const DerivedMap a = DerivedMapGenerator::rasterise(m, per, o, &noDil); + + o.dilationPixels = 3; + DerivedMapGenerator::Report withDil; + const DerivedMap b = DerivedMapGenerator::rasterise(m, per, o, &withDil); + + ASSERT_TRUE(noDil.ok); + ASSERT_TRUE(withDil.ok); + EXPECT_EQ(noDil.texelsDilated, 0); + EXPECT_GT(withDil.texelsDilated, 0); + int covA = 0, covB = 0; + for (size_t i = 0; i < a.coverage.size(); ++i) { covA += a.coverage[i]; covB += b.coverage[i]; } + EXPECT_GT(covB, covA) << "dilation must grow the covered region"; +} + +TEST(DerivedMapGeneratorTest, GenerateCavityOnValleyProducesSignal) { + DerivedMapGenerator::Options o; + o.resolution = 64; + o.contrast = 2.0f; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::generate( + valley(), DerivedMapKind::Cavity, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_GT(rep.texelsRasterised, 0); + // The trough runs down the middle of UV space (u=0.5) while the outer + // walls are at u=0 / u=1, so the centre must be dirtier than the edge. + const float centre = map.sample(0.5f, 0.5f); + const float edge = map.sample(0.02f, 0.5f); + EXPECT_GT(centre, edge) << "cavity should peak in the trough, not on the walls"; + EXPECT_GT(rep.maxValue, 0.0f); +} + +TEST(DerivedMapGeneratorTest, GenerateRejectsAoAndPointsAtTheRightCall) { + DerivedMapGenerator::Options o; + DerivedMapGenerator::Report rep; + // AO needs scene-side visibility; generate() must refuse rather than + // silently emit an empty/garbage map. + const DerivedMap map = DerivedMapGenerator::generate( + flatQuad(), DerivedMapKind::AmbientOcclusion, o, &rep); + EXPECT_FALSE(rep.ok); + EXPECT_TRUE(rep.error.contains("fromVertexOcclusion")); + EXPECT_TRUE(map.empty()); +} + +TEST(DerivedMapGeneratorTest, FromVertexOcclusionClampsAndRasterises) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + ASSERT_GT(n, 0u); + // Out-of-range inputs must be clamped into 0..1, not stored raw. + std::vector occ(n, 2.5f); + occ[0] = -1.0f; + + DerivedMapGenerator::Options o; + o.resolution = 16; + o.dilationPixels = 0; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::fromVertexOcclusion(m, occ, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_GE(rep.minValue, 0.0f); + EXPECT_LE(rep.maxValue, 1.0f); +} + +TEST(DerivedMapGeneratorTest, SampleClampsOutOfRangeUv) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + std::vector per(n, 0.4f); + DerivedMapGenerator::Options o; + o.resolution = 8; + o.dilationPixels = 0; + const DerivedMap map = DerivedMapGenerator::rasterise(m, per, o, nullptr); + ASSERT_FALSE(map.empty()); + // Must clamp, not index out of bounds. + EXPECT_NEAR(map.sample(-5.0f, -5.0f), map.sample(0.0f, 0.0f), 1e-6f); + EXPECT_NEAR(map.sample(9.0f, 9.0f), map.sample(0.999f, 0.999f), 1e-6f); +} + +TEST(DerivedMapGeneratorTest, KindNamesAreStableForCachePaths) { + // These strings become on-disk filename components — changing one silently + // orphans every cached map, so pin them. + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::Cavity), "cavity"); + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::Curvature), "curvature"); + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::AmbientOcclusion), "ao"); + // Neutral backgrounds differ per kind: curvature's "no effect" is 0.5. + EXPECT_FLOAT_EQ(DerivedMapGenerator::backgroundFor(DerivedMapKind::Curvature), 0.5f); + EXPECT_FLOAT_EQ(DerivedMapGenerator::backgroundFor(DerivedMapKind::Cavity), 0.0f); +} diff --git a/src/DerivedMapOcclusion.cpp b/src/DerivedMapOcclusion.cpp new file mode 100644 index 00000000..c372fbd4 --- /dev/null +++ b/src/DerivedMapOcclusion.cpp @@ -0,0 +1,112 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — per-vertex ambient occlusion from depth maps +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapOcclusion.h" + +#include "ProjectionMath.h" + +#include +#include + +namespace DerivedMapOcclusion { + +bool isVisibleInView(const Ogre::Vector3& worldPos, const DepthView& view) +{ + if (view.depth.isNull()) return false; + + const ProjectionMath::Projected p = + ProjectionMath::projectToViewportUV(worldPos, view.viewProj); + if (p.behind) return false; + if (p.uv.x < 0.0f || p.uv.x > 1.0f || p.uv.y < 0.0f || p.uv.y > 1.0f) return false; + + // Reconstruct the nearest recorded surface distance: grayscale is linear in + // world distance over [depthNear, depthFar], near = bright. + const Ogre::ColourValue d = ProjectionMath::sampleImage(view.depth, p.uv); + const float dMap = view.depthNear + (1.0f - d.r) * (view.depthFar - view.depthNear); + + // Camera-AXIS distance, matching how the fog encoded it. Euclidean would + // over-read off-axis points and make them self-occlude. + float dPoint; + if (!view.camDirection.isZeroLength()) + dPoint = (worldPos - view.camPosition).dotProduct(view.camDirection); + else + dPoint = (worldPos - view.camPosition).length(); + + // A NEGATIVE axis distance means the point is behind the eye plane. The + // perspective `behind` flag above only catches this when w <= 0, which never + // happens under an ORTHOGRAPHIC viewProj (w stays 1), so test it explicitly + // rather than relying on the projection type. + if (dPoint < 0.0f) return false; + + return dPoint <= dMap + view.biasWorld; +} + +float occlusionAt(const Ogre::Vector3& worldPos, + const Ogre::Vector3& worldNormal, + const std::vector& views) +{ + if (views.empty()) return 0.0f; + + Ogre::Vector3 n = worldNormal; + const bool haveNormal = !n.isZeroLength(); + if (haveNormal) n.normalise(); + + int considered = 0; + int blocked = 0; + for (const DepthView& v : views) { + if (v.depth.isNull()) continue; + if (haveNormal && !v.camDirection.isZeroLength()) { + // The camera looks along camDirection, so it sits on the -camDirection + // side. Only count views on the OUTWARD side of the surface: a view + // from behind the face cannot contribute to how lit that face is, + // and counting it would darken everything by ~half uniformly. + const float facing = n.dotProduct(-v.camDirection); + if (facing <= 0.0f) continue; + } + ++considered; + if (!isVisibleInView(worldPos, v)) ++blocked; + } + if (considered == 0) return 0.0f; // nothing could see it either way + return static_cast(blocked) / static_cast(considered); +} + +std::vector occlusionForVertices(const std::vector& positions, + const std::vector& normals, + const std::vector& views) +{ + std::vector out(positions.size(), 0.0f); + for (size_t i = 0; i < positions.size(); ++i) { + const Ogre::Vector3 n = (i < normals.size()) ? normals[i] : Ogre::Vector3::ZERO; + out[i] = occlusionAt(positions[i], n, views); + } + return out; +} + +std::vector sampleDirections(int count) +{ + const int n = std::max(1, count); + std::vector dirs; + dirs.reserve(static_cast(n)); + + // Fibonacci lattice: near-uniform over the sphere with no clustering at the + // poles (which a naive lat/long grid would give, biasing AO vertically). + const float golden = static_cast(M_PI) * (3.0f - std::sqrt(5.0f)); + for (int i = 0; i < n; ++i) { + // y from +1 to -1, evenly in the z-axis-projected sense. + const float y = (n == 1) ? 0.0f + : 1.0f - 2.0f * (static_cast(i) / static_cast(n - 1)); + const float r = std::sqrt(std::max(0.0f, 1.0f - y * y)); + const float theta = golden * static_cast(i); + dirs.emplace_back(std::cos(theta) * r, y, std::sin(theta) * r); + dirs.back().normalise(); + } + return dirs; +} + +} // namespace DerivedMapOcclusion diff --git a/src/DerivedMapOcclusion.h b/src/DerivedMapOcclusion.h new file mode 100644 index 00000000..d24abe21 --- /dev/null +++ b/src/DerivedMapOcclusion.h @@ -0,0 +1,84 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — per-vertex ambient occlusion from depth maps +(Paint v2 Slice G, issue #550) + +The issue proposed "short-ray hemispherical occlusion" on the CPU. There is no +BVH/kd-tree anywhere in the repo and every existing ray query is a brute-force +linear scan, so instead of adding an acceleration structure this reuses the +depth-map visibility test already proven by ProjectionPainter::OcclusionMap +(#549): render the mesh's depth from N directions, then for each vertex count +how many of those directions can actually see it. That fraction, inverted, is +the occlusion. + +The MATH is pure data (a vertex + a set of DepthView records -> a 0..1 scalar), +so it is unit-testable headlessly with synthetic depth images. The RENDERING of +those views touches the Ogre scene and lives in the controller. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPOCCLUSION_H +#define DERIVEDMAPOCCLUSION_H + +#include +#include + +#include + +#include + +/// One rendered depth view: the grayscale depth image plus the exact matrices +/// used, so a world point can be projected back into it. Mirrors the fields of +/// MeshDepthRenderer::RenderResult / ProjectionPainter::OcclusionMap that the +/// visibility test actually needs. +struct DepthView { + QImage depth; ///< grayscale, near=bright / far=dark + Ogre::Matrix4 viewProj; ///< proj * view used to render `depth` + Ogre::Vector3 camPosition = Ogre::Vector3::ZERO; + Ogre::Vector3 camDirection = Ogre::Vector3::ZERO; ///< normalised + float depthNear = 0.0f; + float depthFar = 0.0f; + /// World-space slop before a texel counts as occluded. Without this a + /// surface occludes ITSELF at grazing angles (depth acne) — the same + /// problem, and the same fix, as ProjectionPainter's occlusion path. + float biasWorld = 0.0f; +}; + +namespace DerivedMapOcclusion { + +/// Is `worldPos` visible in `view`? False when it is behind the camera, falls +/// outside the depth image, or sits further along the camera axis than the +/// nearest recorded surface (plus `biasWorld`). +/// +/// Distance is measured along the CAMERA AXIS (dot of (p - eye) with the view +/// direction), NOT Euclidean: the depth map encodes linear fog distance along +/// that axis, so a Euclidean comparison reads off-axis points as further than +/// they were encoded and self-occludes them. +bool isVisibleInView(const Ogre::Vector3& worldPos, const DepthView& view); + +/// Fraction of `views` that CANNOT see `worldPos`, in 0..1 (1 = fully +/// occluded). Views whose direction faces away from `worldNormal` are skipped — +/// they are behind the surface, so counting them would darken every vertex by +/// roughly half regardless of geometry. If no view faces the normal, returns 0 +/// (unoccluded) rather than a misleading 1. +float occlusionAt(const Ogre::Vector3& worldPos, + const Ogre::Vector3& worldNormal, + const std::vector& views); + +/// Convenience: occlusion for every vertex, in the same order as the input. +/// `positions` and `normals` must be the same length. +std::vector occlusionForVertices(const std::vector& positions, + const std::vector& normals, + const std::vector& views); + +/// Evenly distributed unit directions over the sphere (Fibonacci lattice), used +/// as the set of view directions to render. `count` is clamped to >= 1. +/// Sphere rather than hemisphere because a mesh is sampled from all sides; the +/// per-vertex normal test then selects the relevant half for each vertex. +std::vector sampleDirections(int count); + +} // namespace DerivedMapOcclusion + +#endif // DERIVEDMAPOCCLUSION_H diff --git a/src/DerivedMapOcclusion_test.cpp b/src/DerivedMapOcclusion_test.cpp new file mode 100644 index 00000000..b8344b15 --- /dev/null +++ b/src/DerivedMapOcclusion_test.cpp @@ -0,0 +1,196 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapOcclusion unit tests (Paint v2 Slice G, issue #550) + +Pure-data: hand-builds DepthViews with synthetic flat depth images (the same +trick ProjectionPainter_test.cpp uses) so the visibility maths is checked without +rendering anything. No Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapOcclusion.h" + +#include + +#include +#include + +namespace { + +// Orthographic viewProj looking down -Z from +Z, matching ProjectionPainter's +// test helper: x/y in [-halfExtent, halfExtent] map to NDC [-1, 1]. +Ogre::Matrix4 orthoFromPlusZ(float eyeZ, float halfExtent, float zNear, float zFar) +{ + Ogre::Matrix4 view = Ogre::Matrix4::IDENTITY; + view[2][2] = -1.0f; // look toward -Z + view[2][3] = eyeZ; + Ogre::Matrix4 proj = Ogre::Matrix4::ZERO; + proj[0][0] = 1.0f / halfExtent; + proj[1][1] = 1.0f / halfExtent; + proj[2][2] = -2.0f / (zFar - zNear); + proj[2][3] = -(zFar + zNear) / (zFar - zNear); + proj[3][3] = 1.0f; + return proj * view; +} + +// A depth image where every pixel encodes the SAME world distance. +QImage flatDepth(int size, float surfaceDist, float near, float far) +{ + const float g = 1.0f - (surfaceDist - near) / (far - near); // near -> 1 + const int v = std::clamp(static_cast(g * 255.0f + 0.5f), 0, 255); + QImage img(size, size, QImage::Format_RGBA8888); + img.fill(QColor(v, v, v, 255)); + return img; +} + +// A view from +Z looking toward -Z, whose depth map says the nearest surface +// sits at `surfaceDist` from the eye. +DepthView viewFromPlusZ(float surfaceDist, float near = 4.0f, float far = 8.0f) +{ + DepthView v; + v.viewProj = orthoFromPlusZ(5.0f, 2.0f, 0.1f, 20.0f); + v.camPosition = Ogre::Vector3(0, 0, 5); + v.camDirection = Ogre::Vector3(0, 0, -1); + v.depthNear = near; + v.depthFar = far; + v.depth = flatDepth(32, surfaceDist, near, far); + v.biasWorld = 0.05f; + return v; +} + +} // namespace + +TEST(DerivedMapOcclusionTest, PointOnTheVisibleSurfaceIsVisible) { + // Surface recorded at distance 5; the point at z=0 IS that surface. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_TRUE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, PointBehindTheSurfaceIsOccluded) { + // Surface at distance 5, point at z=-2 (distance 7) is behind it. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, -2), v)); +} + +TEST(DerivedMapOcclusionTest, BiasPreventsSelfOcclusion) { + // A point marginally behind the recorded surface (within bias) must still + // count as visible — otherwise a surface occludes itself (depth acne) and + // every vertex reads fully occluded. + DepthView v = viewFromPlusZ(5.0f); + const Ogre::Vector3 justBehind(0, 0, -0.02f); // distance 5.02, bias 0.05 + EXPECT_TRUE(DerivedMapOcclusion::isVisibleInView(justBehind, v)); + v.biasWorld = 0.0f; + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(justBehind, v)); +} + +TEST(DerivedMapOcclusionTest, OffImagePointIsNotVisible) { + const DepthView v = viewFromPlusZ(5.0f); + // halfExtent is 2, so x=10 projects outside the depth image. + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(10, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, PointBehindCameraIsNotVisible) { + // z=50 is behind the eye (at z=5) looking toward -Z, so its camera-axis + // distance is NEGATIVE. Note the perspective `behind` (w <= 0) flag cannot + // catch this here: these fixtures use an ORTHOGRAPHIC viewProj where w stays + // 1, so the sign of the axis distance is the only thing that rejects it. + // Depth-map views in this feature are auto-framed orthographic-ish renders, + // so that is the case that actually matters. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 50), v)); +} + +TEST(DerivedMapOcclusionTest, NullDepthImageIsNotVisible) { + DepthView v = viewFromPlusZ(5.0f); + v.depth = QImage(); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, ExposedPointHasZeroOcclusion) { + // One view that can see the point, and the point is the visible surface. + const std::vector views{viewFromPlusZ(5.0f)}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), views); + EXPECT_NEAR(occ, 0.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, BuriedPointIsFullyOccluded) { + // The depth map says the nearest surface is at 5, but the point is at 7. + const std::vector views{viewFromPlusZ(5.0f)}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, -2), Ogre::Vector3(0, 0, 1), views); + EXPECT_NEAR(occ, 1.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, BackFacingViewsAreSkippedNotCountedAsOccluding) { + // A view looking at the BACK of the surface must be ignored entirely. If it + // were counted as "cannot see" the occlusion of a fully exposed vertex + // would read ~0.5 instead of 0, darkening the whole mesh uniformly. + DepthView behind = viewFromPlusZ(5.0f); + behind.camPosition = Ogre::Vector3(0, 0, -5); + behind.camDirection = Ogre::Vector3(0, 0, 1); // looks toward +Z + + const std::vector views{viewFromPlusZ(5.0f), behind}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), views); // normal +Z + EXPECT_NEAR(occ, 0.0f, 1e-5f) << "the back-facing view must not contribute"; +} + +TEST(DerivedMapOcclusionTest, NoFacingViewYieldsZeroNotOne) { + // If nothing faces the normal we know nothing — report unoccluded rather + // than fully occluded, which would black out the map. + DepthView behind = viewFromPlusZ(5.0f); + behind.camPosition = Ogre::Vector3(0, 0, -5); + behind.camDirection = Ogre::Vector3(0, 0, 1); + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), {behind}); + EXPECT_NEAR(occ, 0.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, EmptyViewSetIsUnoccluded) { + EXPECT_NEAR(DerivedMapOcclusion::occlusionAt( + Ogre::Vector3::ZERO, Ogre::Vector3::UNIT_Z, {}), 0.0f, 1e-6f); +} + +TEST(DerivedMapOcclusionTest, PerVertexHelperMatchesPerPointCall) { + const std::vector views{viewFromPlusZ(5.0f)}; + const std::vector pos{{0, 0, 0}, {0, 0, -2}}; + const std::vector nrm{{0, 0, 1}, {0, 0, 1}}; + const auto out = DerivedMapOcclusion::occlusionForVertices(pos, nrm, views); + ASSERT_EQ(out.size(), 2u); + EXPECT_NEAR(out[0], 0.0f, 1e-5f); + EXPECT_NEAR(out[1], 1.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, PerVertexHelperToleratesMissingNormals) { + const std::vector views{viewFromPlusZ(5.0f)}; + const std::vector pos{{0, 0, 0}, {0, 0, 0}}; + // Shorter normals array must not read out of bounds. + const auto out = DerivedMapOcclusion::occlusionForVertices(pos, {}, views); + EXPECT_EQ(out.size(), 2u); +} + +TEST(DerivedMapOcclusionTest, SampleDirectionsAreUnitAndSpreadOverTheSphere) { + const auto dirs = DerivedMapOcclusion::sampleDirections(32); + ASSERT_EQ(dirs.size(), 32u); + Ogre::Vector3 sum = Ogre::Vector3::ZERO; + for (const auto& d : dirs) { + EXPECT_NEAR(d.length(), 1.0f, 1e-4f); + sum += d; + } + // Near-uniform over the sphere => the mean direction is near zero. A + // pole-clustered lat/long grid would fail this and bias AO vertically. + EXPECT_LT(sum.length() / 32.0f, 0.2f); +} + +TEST(DerivedMapOcclusionTest, SampleDirectionsHandlesDegenerateCounts) { + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(1).size(), 1u); + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(0).size(), 1u); // clamped + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(-5).size(), 1u); + for (const auto& d : DerivedMapOcclusion::sampleDirections(1)) + EXPECT_NEAR(d.length(), 1.0f, 1e-4f); +} From fbb1d8c9e2a9056cf651548e0972dedf6beedf40 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 25 Aug 2026 02:55:05 -0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(#550):=20Slice=20G=20part=202=20?= =?UTF-8?q?=E2=80=94=20controller=20wiring,=20brush=20mask,=20recipes,=20Q?= =?UTF-8?q?ML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of Paint v2 Slice G: hooks the pure-data generators into the paint controller and surfaces them in the Inspector. **Controller** — WRITE-backed props (kind / brush-mask / strength / invert / contrast) plus read-only readiness + status, all notified by derivedMapChanged. `computeDerivedMap()` walks memory -> disk cache -> bake, so AO is not re-baked per session; `recomputeDerivedMaps()` is the issue's "Recalculate derived maps". Invalidation is the content hash agreed for this slice, not a revision counter: `invalidateDerivedMapsIfMeshChanged()` drops in-memory maps when the mesh hash moves, and `derivedMapReady()` reports false while the cached hash and the live geometry disagree — so a topology edit can never leave a stale map bound to a changed surface. `setDerivedMapContrast` also clears the cache, since contrast feeds the GENERATOR rather than the lookup. **AO** renders 12 evenly-spread depth views (Fibonacci directions) through MeshDepthRenderer and reduces them via DerivedMapOcclusion. Vertices are read from the same HalfEdgeMesh weld the rasteriser uses, so the occlusion array lines up index-for-index with its per-vertex input. Normals use the inverse transpose so non-uniform scale cannot tilt them off the surface. The occlusion bias is max(one grayscale step of the encoded range x2, 1% of the bounds radius) — below that, depth quantisation alone makes a surface occlude itself. A view that fails to render is skipped rather than aborting the whole bake. **Brush modulation** wraps the colorAt callback rather than editing each branch, so the tiling, stamp and gradient paths inherit it for free (the same shape as the existing TilingSource wrap). Two details that would otherwise silently break it: - It scales the colour's ALPHA, not RGB. Scaling RGB would drag the paint toward black in cavities instead of hiding it there. - The two scalar fast paths (solid colour, and GradientLinear) collapse the brush to one colour and use the paintBrush overload that never calls colorAt, so they are skipped while a map is modulating. Otherwise enabling the mask would appear to do nothing for the most common brush setup. `multiplyBlendByColorAlpha` is passed so the alpha we scale actually gates coverage. **Layer masks** — `applyDerivedMapToLayerMask()` fills the active layer's maskAlpha via PaintLayerStack::ensureLayerMask (which existed with the compositor honouring it, but had no non-test caller until now). It samples by UV rather than assuming a 1:1 texel mapping, since a map may be baked at a different resolution than the paint buffer. Undoable through the existing pushLayerOpUndo snapshot path. **Recipes** — "Edge wear" (inverted curvature, bare metal), "Crevice dirt" (cavity, dark grime), "AO darken" (AO, black, Multiply blend). Each adds its own masked Generated layer as one undo step. A recipe temporarily switches kind to bake its own map and then RESTORES the user's picker selection, so clicking a recipe does not silently retarget the UI. **QML** — a collapsible "Cavity / Curvature / AO" group (the panel is dense and this is occasional-use): kind picker, Bake / Recalculate with a readiness dot, status line, brush-mask + invert toggles, strength/contrast sliders, "Mask active layer", and the three recipe buttons. Breadcrumbs: paint.derived_map / .bake / .cache_hit / .cache_write_failed / .error / .layer_mask / .recipe / .recalculate. Verification status: builds clean, and `qmllint` reports ZERO errors across the file (only the pre-existing "Unqualified access" warnings that are this file's established idiom). The app launches and stays up, but its stdout/stderr are redirected internally, so runtime QML warnings could NOT be captured from a shell here — the QML group has therefore been verified statically only, and the bake/recipe buttons have not yet been exercised interactively. Co-Authored-By: Claude Opus 5 (1M context) --- qml/PropertiesPanel.qml | 200 ++++++++++++++ src/TexturePaintController.cpp | 477 ++++++++++++++++++++++++++++++++- src/TexturePaintController.h | 67 +++++ 3 files changed, 740 insertions(+), 4 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index a31b812b..d9994215 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4546,6 +4546,15 @@ Rectangle { property bool cameraLocked: TexturePaintController.cameraLocked property bool decalActive: TexturePaintController.decalSessionActive property int decalState: TexturePaintController.decalState + // Paint v2 Slice G — cavity / curvature / AO derived maps (#550). + property int derivedKind: TexturePaintController.derivedMapKind + property bool derivedAsBrushMask: TexturePaintController.derivedMapAsBrushMask + property real derivedStrength: TexturePaintController.derivedMapStrength + property bool derivedInvert: TexturePaintController.derivedMapInvert + property real derivedContrast: TexturePaintController.derivedMapContrast + property bool derivedReady: TexturePaintController.derivedMapReady + property string derivedStatus: TexturePaintController.derivedMapStatus + property bool derivedExpanded: false // Projection group collapse state (UI density — the group is off by // default so it starts collapsed). property bool projExpanded: false @@ -4617,6 +4626,15 @@ Rectangle { texPaintCol.decalActive = TexturePaintController.decalSessionActive texPaintCol.decalState = TexturePaintController.decalState } + function onDerivedMapChanged() { + texPaintCol.derivedKind = TexturePaintController.derivedMapKind + texPaintCol.derivedAsBrushMask = TexturePaintController.derivedMapAsBrushMask + texPaintCol.derivedStrength = TexturePaintController.derivedMapStrength + texPaintCol.derivedInvert = TexturePaintController.derivedMapInvert + texPaintCol.derivedContrast = TexturePaintController.derivedMapContrast + texPaintCol.derivedReady = TexturePaintController.derivedMapReady + texPaintCol.derivedStatus = TexturePaintController.derivedMapStatus + } } Text { @@ -5138,6 +5156,188 @@ Rectangle { font.pixelSize: 9; opacity: 0.7; wrapMode: Text.Wrap } + // ---- Derived maps: cavity / curvature / AO (Paint v2 Slice G #550) ---- + // Collapsible: this is an occasional-use group, and the panel is + // already dense. + Rectangle { + width: parent.width - 16 + height: 22; radius: 4 + color: PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.left: parent.left; anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: (texPaintCol.derivedExpanded ? "\u25be " : "\u25b8 ") + + "Cavity / Curvature / AO" + color: PropertiesPanelController.textColor; font.pixelSize: 10 + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: texPaintCol.derivedExpanded = !texPaintCol.derivedExpanded } + } + + Column { + visible: texPaintCol.derivedExpanded + width: parent.width - 16 + spacing: 6 + + // Map picker. Indices match DerivedMapKind, so the comparison + // below is index-based like the channel picker. + Row { + spacing: 4 + Repeater { + model: ["Cavity", "Curvature", "AO"] + Rectangle { + width: 62; height: 22; radius: 4 + color: texPaintCol.derivedKind === index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: modelData + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.derivedMapKind = index } + } + } + } + + Row { + spacing: 6 + Rectangle { + width: 74; height: 22; radius: 4 + color: PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Bake" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.computeDerivedMap() } + } + Rectangle { + width: 96; height: 22; radius: 4 + color: PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Recalculate" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.recomputeDerivedMaps() } + } + Rectangle { + width: 16; height: 16; radius: 8 + anchors.verticalCenter: parent.verticalCenter + color: texPaintCol.derivedReady ? "#4caf50" : "#8a8a8a" + border.color: PropertiesPanelController.borderColor; border.width: 1 + } + } + + Text { + width: parent.width + visible: texPaintCol.derivedStatus !== "" + text: texPaintCol.derivedStatus + color: PropertiesPanelController.textColor + font.pixelSize: 9; opacity: 0.7; wrapMode: Text.Wrap + } + + // Use the map to gate the brush. + Row { + spacing: 6 + Rectangle { + width: 108; height: 22; radius: 4 + color: texPaintCol.derivedAsBrushMask + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Mask the brush" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.derivedMapAsBrushMask = + !texPaintCol.derivedAsBrushMask } + } + Rectangle { + width: 62; height: 22; radius: 4 + color: texPaintCol.derivedInvert + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Invert" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.derivedMapInvert = + !texPaintCol.derivedInvert } + } + } + + Row { + spacing: 6 + Text { + text: "Strength"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 110 + from: 0.0; to: 1.0; stepSize: 0.01 + value: texPaintCol.derivedStrength + onMoved: TexturePaintController.derivedMapStrength = value + } + Text { + text: texPaintCol.derivedStrength.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 6 + Text { + text: "Contrast"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 110 + from: 0.1; to: 8.0; stepSize: 0.1 + value: texPaintCol.derivedContrast + onMoved: TexturePaintController.derivedMapContrast = value + } + Text { + text: texPaintCol.derivedContrast.toFixed(1) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Rectangle { + width: 150; height: 22; radius: 4 + color: PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Mask active layer" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.applyDerivedMapToLayerMask() } + } + + // One-click recipes: each adds its own masked layer. + Text { + text: "One-click recipes" + color: PropertiesPanelController.textColor + font.pixelSize: 9; opacity: 0.7 + } + Row { + spacing: 4 + Repeater { + model: ["Edge wear", "Crevice dirt", "AO darken"] + Rectangle { + width: 76; height: 22; radius: 4 + color: PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: modelData + color: PropertiesPanelController.textColor; font.pixelSize: 9 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.applyDerivedMapRecipe(index) } + } + } + } + } // end collapsible Derived maps body + // Texture slot picker \u2014 populated by selection (advanced override: // lets the user target a specific TUS regardless of channel mapping) Row { diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 1e954b6a..e6d7155a 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -19,6 +19,9 @@ #include "TextureChannelPacker.h" #include "MeshImporterExporter.h" #include "ProjectionPainter.h" +#include "DerivedMapCache.h" +#include "DerivedMapOcclusion.h" +#include "HalfEdgeMesh.h" #include "MultiViewTextureBaker.h" #include "MeshDepthRenderer.h" #include "PaintLayerStack.h" @@ -1996,7 +1999,32 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl const float wavelength = std::max(radiusUv * 4.0f, 0.05f); const float strokeT = BrushEngine::linearStrokeT( m_strokePathLength, wavelength, m_strokePhaseJitter); - const auto colorAt = buildBrushColorAtFn(strokeT); + auto colorAt = buildBrushColorAtFn(strokeT); + + // Paint v2 Slice G (#550): modulate the brush colour by the derived map at + // the painted UV, so a stroke lands only in crevices / on edges. Wrapping + // colorAt (rather than each branch) means the tiling, stamp and gradient + // paths all inherit it for free — the same shape as the TilingSource wrap + // just below. + const bool derivedMod = m_derivedMapAsBrushMask + && m_derivedMapStrength > 0.0 + && activeDerivedMap() != nullptr; + if (derivedMod) { + const Ogre::Vector2 center = uv; + const float radiusCopy = radiusUv; + auto inner = colorAt; + colorAt = [this, inner, center, radiusCopy](float dx, float dy) { + float mu = 0.0f, mv = 0.0f; + BrushFootprint::brushOffsetToUv(center.x, center.y, radiusCopy, dx, dy, mu, mv); + const float f = derivedMapFactorAt(Ogre::Vector2(mu, mv)); + Ogre::ColourValue c = inner(dx, dy); + // Scale ALPHA, not RGB: the map decides how much of the stroke + // lands, not what colour it is. Scaling RGB would darken the paint + // toward black in cavities instead of hiding it. + c.a *= f; + return c; + }; + } if (m_footprintType == BrushFootprint::FootprintType::TilingSource) { if (m_tilingImage.empty()) @@ -2049,17 +2077,23 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl > 0; } - if (m_colorSource != ColorGradient) { + // NB both fast paths below collapse the brush to ONE colour and use the + // scalar paintBrush overload, which never calls colorAt — so they must be + // skipped while a derived map is modulating per-texel. + if (!derivedMod && m_colorSource != ColorGradient) { const QColor qc = texturePaintColor(); const Ogre::ColourValue paint(qc.redF(), qc.greenF(), qc.blueF(), qc.alphaF()); return activePaintBuffer().paintBrush(uv, radiusUv, paint, strength, falloff, shape) > 0; } - if (m_gradientMode == GradientLinear) { + if (!derivedMod && m_gradientMode == GradientLinear) { const auto sampled = colorAt(0.0f, 0.0f); return activePaintBuffer().paintBrush(uv, radiusUv, sampled, strength, falloff, shape) > 0; } - return activePaintBuffer().paintBrush(uv, radiusUv, colorAt, strength, falloff, shape) > 0; + // multiplyBlendByColorAlpha: the derived-map modulation lives in the colour's + // ALPHA, so the blend must honour it (same as the TilingSource path). + return activePaintBuffer().paintBrush(uv, radiusUv, colorAt, strength, falloff, shape, + /*multiplyBlendByColorAlpha=*/derivedMod) > 0; } void TexturePaintController::setPaintTarget(int target) @@ -7245,3 +7279,438 @@ void TexturePaintController::flushPaintTextureForExport(Ogre::Entity* entity) QStringLiteral("Flushed %1-layer composite before export") .arg(m_layerStack.layerCount())); } + +// --------------------------------------------------------------------------- +// Paint v2 Slice G (#550): cavity / curvature / AO derived maps +// --------------------------------------------------------------------------- + +void TexturePaintController::setDerivedMapKind(int kind) +{ + const int k = std::clamp(kind, 0, 2); + if (k == m_derivedMapKind) return; + m_derivedMapKind = k; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("kind=%1").arg(QLatin1String( + DerivedMapGenerator::kindName(static_cast(k))))); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapAsBrushMask(bool on) +{ + if (on == m_derivedMapAsBrushMask) return; + m_derivedMapAsBrushMask = on; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("brushMask=%1").arg(on)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapStrength(double v) +{ + const double c = std::clamp(v, 0.0, 1.0); + if (std::abs(c - m_derivedMapStrength) < 1e-6) return; + m_derivedMapStrength = c; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("strength=%1").arg(c, 0, 'f', 3)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapInvert(bool on) +{ + if (on == m_derivedMapInvert) return; + m_derivedMapInvert = on; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("invert=%1").arg(on)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapContrast(double v) +{ + const double c = std::clamp(v, 0.1, 8.0); + if (std::abs(c - m_derivedMapContrast) < 1e-6) return; + m_derivedMapContrast = c; + // Contrast feeds the GENERATOR, so any cached map is now stale. + m_derivedMaps.clear(); + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("contrast=%1").arg(c, 0, 'f', 2)); + emit derivedMapChanged(); +} + +QString TexturePaintController::currentMeshHash() const +{ + if (!m_paintMesh) return {}; + return DerivedMapCache::meshHash(*m_paintMesh); +} + +void TexturePaintController::invalidateDerivedMapsIfMeshChanged() +{ + const QString h = currentMeshHash(); + if (h == m_derivedMapMeshHash) return; + // The geometry changed (or the mesh went away): the maps no longer describe + // this surface. This is the content-hash invalidation that replaces the + // "EditableMesh revision counter" the issue proposed — EditableMesh has no + // such counter, and its public mutable subMeshes() accessor means one could + // be bypassed without incrementing. + m_derivedMaps.clear(); + m_derivedMapMeshHash = h; +} + +const DerivedMap* TexturePaintController::activeDerivedMap() const +{ + const auto it = m_derivedMaps.find(m_derivedMapKind); + if (it == m_derivedMaps.end() || it->second.empty()) return nullptr; + return &it->second; +} + +bool TexturePaintController::derivedMapReady() const +{ + if (!m_paintMesh) return false; + // Only "ready" while the cached map still matches the live geometry. + if (m_derivedMapMeshHash != currentMeshHash()) return false; + return activeDerivedMap() != nullptr; +} + +float TexturePaintController::derivedMapFactorAt(const Ogre::Vector2& uv) const +{ + const DerivedMap* map = activeDerivedMap(); + if (!map) return 1.0f; // no map => no modulation, callers need no branch + float v = map->sample(uv.x, uv.y); + if (m_derivedMapInvert) v = 1.0f - v; + // Blend toward 1 (unmodulated) by strength, so 0 is a true no-op. + const float s = static_cast(m_derivedMapStrength); + return std::clamp(1.0f + s * (v - 1.0f), 0.0f, 1.0f); +} + +std::vector TexturePaintController::computeVertexOcclusion(QString* errorOut) const +{ + auto* entity = m_paintMeshEntity; + if (!entity || !m_paintMesh) { + if (errorOut) *errorOut = QStringLiteral("no painted mesh"); + return {}; + } + + // Welded vertex positions/normals, in the SAME order DerivedMapGenerator + // rasterises, so the occlusion array lines up with its per-vertex input. + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(*m_paintMesh)) { + if (errorOut) *errorOut = QStringLiteral("could not weld the mesh"); + return {}; + } + auto* node = entity->getParentSceneNode(); + const Ogre::Affine3 world = node ? node->_getFullTransform() : Ogre::Affine3::IDENTITY; + const Ogre::Matrix3 normalMat = world.linear().Inverse().Transpose(); + + std::vector pos, nrm; + pos.reserve(he.vertexCount()); + nrm.reserve(he.vertexCount()); + for (size_t i = 0; i < he.vertexCount(); ++i) { + const HEVertex& v = he.vertex(static_cast(i)); + pos.push_back(world * v.position); + Ogre::Vector3 n = v.hasNormal ? (normalMat * v.normal) : Ogre::Vector3::ZERO; + if (!n.isZeroLength()) n.normalise(); + nrm.push_back(n); + } + + // Render depth from evenly spread directions. 12 is enough to separate + // crevices from exposed surfaces without making the bake feel stalled; + // each render is a full RTT pass on the main thread. + constexpr int kViewCount = 12; + constexpr int kDepthSize = 256; + const auto dirs = DerivedMapOcclusion::sampleDirections(kViewCount); + const Ogre::AxisAlignedBox aabb = entity->getWorldBoundingBox(true); + const float radius = aabb.isNull() ? 1.0f : aabb.getHalfSize().length(); + + std::vector views; + views.reserve(dirs.size()); + for (const Ogre::Vector3& d : dirs) { + MeshDepthRenderer::View mv; + mv.dir = d; + // Any up not parallel to dir; the renderer re-frames the camera itself. + mv.up = (std::abs(d.dotProduct(Ogre::Vector3::UNIT_Y)) > 0.95f) + ? Ogre::Vector3(0, 0, 1) : Ogre::Vector3::UNIT_Y; + mv.name = "ao"; + MeshDepthRenderer::RenderResult r = + MeshDepthRenderer::renderDepthMapView(entity, kDepthSize, mv, nullptr); + if (r.depth.isNull()) continue; // skip a failed view rather than abort + DepthView dv; + dv.depth = r.depth; + dv.viewProj = r.projMatrix * r.viewMatrix; + dv.camPosition = r.camPosition; + dv.camDirection = r.camDirection; + dv.depthNear = r.depthNear; + dv.depthFar = r.depthFar; + // Bias must exceed one grayscale step of the encoded range, else + // quantisation alone makes a surface occlude itself (depth acne). + dv.biasWorld = std::max((r.depthFar - r.depthNear) / 255.0f * 2.0f, + radius * 0.01f); + views.push_back(std::move(dv)); + } + if (views.empty()) { + if (errorOut) *errorOut = QStringLiteral("depth rendering unavailable"); + return {}; + } + return DerivedMapOcclusion::occlusionForVertices(pos, nrm, views); +} + +bool TexturePaintController::computeDerivedMap() +{ + if (!m_paintMesh) { + m_derivedMapStatus = QStringLiteral("Select a mesh and start painting first."); + emit derivedMapChanged(); + return false; + } + invalidateDerivedMapsIfMeshChanged(); + + const auto kind = static_cast(m_derivedMapKind); + const QString kindStr = QLatin1String(DerivedMapGenerator::kindName(kind)); + + // Already in memory for this exact geometry. + if (activeDerivedMap()) { + m_derivedMapStatus = QStringLiteral("%1 map ready.").arg(kindStr); + emit derivedMapChanged(); + return true; + } + + const QString hash = currentMeshHash(); + + // Disk cache next — the whole point is not re-baking AO on every session. + { + DerivedMap cached; + QString err; + if (DerivedMapCache::load(hash, kind, cached, err) && !cached.empty()) { + m_derivedMaps[m_derivedMapKind] = std::move(cached); + m_derivedMapMeshHash = hash; + m_derivedMapStatus = QStringLiteral("%1 map loaded from cache.").arg(kindStr); + SentryReporter::addBreadcrumb("paint.derived_map.cache_hit", kindStr); + emit derivedMapChanged(); + return true; + } + } + + DerivedMapGenerator::Options opts; + opts.resolution = std::max(64, m_buffer.width() > 0 ? m_buffer.width() : 1024); + opts.contrast = static_cast(m_derivedMapContrast); + + DerivedMapGenerator::Report rep; + DerivedMap map; + if (kind == DerivedMapKind::AmbientOcclusion) { + QString err; + const std::vector occ = computeVertexOcclusion(&err); + if (occ.empty()) { + m_derivedMapStatus = QStringLiteral("AO bake failed: %1").arg( + err.isEmpty() ? QStringLiteral("unknown error") : err); + SentryReporter::addBreadcrumb("paint.derived_map.error", m_derivedMapStatus); + emit derivedMapChanged(); + return false; + } + map = DerivedMapGenerator::fromVertexOcclusion(*m_paintMesh, occ, opts, &rep); + } else { + map = DerivedMapGenerator::generate(*m_paintMesh, kind, opts, &rep); + } + + if (!rep.ok || map.empty()) { + m_derivedMapStatus = QStringLiteral("%1 bake failed: %2").arg(kindStr, + rep.error.isEmpty() ? QStringLiteral("no output") : rep.error); + SentryReporter::addBreadcrumb("paint.derived_map.error", m_derivedMapStatus); + emit derivedMapChanged(); + return false; + } + + QString saveErr; + if (!DerivedMapCache::save(hash, kind, map, saveErr)) { + // A cache write failure must not fail the bake — the map is usable now. + SentryReporter::addBreadcrumb("paint.derived_map.cache_write_failed", saveErr); + } + m_derivedMaps[m_derivedMapKind] = std::move(map); + m_derivedMapMeshHash = hash; + m_derivedMapStatus = QStringLiteral("%1 map baked (%2 texels).") + .arg(kindStr).arg(rep.texelsRasterised); + SentryReporter::addBreadcrumb("paint.derived_map.bake", + QStringLiteral("%1 texels=%2 dilated=%3") + .arg(kindStr).arg(rep.texelsRasterised).arg(rep.texelsDilated)); + emit derivedMapChanged(); + return true; +} + +bool TexturePaintController::recomputeDerivedMaps() +{ + const QString hash = currentMeshHash(); + if (!hash.isEmpty()) DerivedMapCache::invalidateAll(hash); + m_derivedMaps.clear(); + m_derivedMapMeshHash.clear(); + SentryReporter::addBreadcrumb("paint.derived_map.recalculate", hash); + return computeDerivedMap(); +} + +bool TexturePaintController::applyDerivedMapToLayerMask() +{ + if (!hasActiveSession()) { + m_derivedMapStatus = QStringLiteral("No paint session."); + emit derivedMapChanged(); + return false; + } + if (!derivedMapReady() && !computeDerivedMap()) return false; + const DerivedMap* map = activeDerivedMap(); + if (!map) return false; + + const int idx = m_layerStack.activeIndex(); + if (idx < 0) { + m_derivedMapStatus = QStringLiteral("No active layer."); + emit derivedMapChanged(); + return false; + } + + const auto before = m_layerStack.snapshot(); + std::vector& mask = m_layerStack.ensureLayerMask(idx); + const int W = m_buffer.width(); + const int H = m_buffer.height(); + if (W <= 0 || H <= 0 || mask.size() != static_cast(W) * H) { + m_derivedMapStatus = QStringLiteral("Layer mask size mismatch."); + emit derivedMapChanged(); + return false; + } + // The map may have been baked at a different resolution than the paint + // buffer, so sample by UV rather than assuming a 1:1 texel mapping. + for (int y = 0; y < H; ++y) { + const float v = (y + 0.5f) / static_cast(H); + for (int x = 0; x < W; ++x) { + const float u = (x + 0.5f) / static_cast(W); + float m = map->sample(u, v); + if (m_derivedMapInvert) m = 1.0f - m; + const float s = static_cast(m_derivedMapStrength); + m = std::clamp(1.0f + s * (m - 1.0f), 0.0f, 1.0f); + mask[static_cast(y) * W + x] = + static_cast(std::lround(m * 255.0f)); + } + } + + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + const QString label = QStringLiteral("Mask from %1").arg( + QLatin1String(DerivedMapGenerator::kindName( + static_cast(m_derivedMapKind)))); + pushLayerOpUndo(label, before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + ++m_layerPreviewVersion; + SentryReporter::addBreadcrumb("paint.derived_map.layer_mask", label); + m_derivedMapStatus = label + QStringLiteral(" applied."); + emit layersChanged(); + emit fullResPreviewChanged(); + emit derivedMapChanged(); + return true; +} + +bool TexturePaintController::applyDerivedMapRecipe(int recipe) +{ + if (!hasActiveSession()) { + if (auto* e = activeEntity()) { (void)e; ensurePaintableTexture(1024); } + if (!hasActiveSession()) { + m_derivedMapStatus = QStringLiteral("No paint session."); + emit derivedMapChanged(); + return false; + } + } + + // Each recipe = (which map, inverted?, fill colour, blend, layer name). + struct Recipe { + DerivedMapKind kind; + bool invert; + Ogre::ColourValue colour; + PaintLayerBlend::Mode blend; + const char* name; + }; + Recipe r; + switch (recipe) { + case 0: // Edge wear: bare metal on CONVEX edges => inverse curvature. + r = {DerivedMapKind::Curvature, false, + Ogre::ColourValue(0.78f, 0.78f, 0.80f, 1.0f), + PaintLayerBlend::Mode::Normal, "Edge wear"}; + // Curvature stores convex BELOW 0.5, so the mask must be inverted to + // select ridges rather than crevices. + r.invert = true; + break; + case 1: // Crevice dirt: dark grime in concave areas => cavity as-is. + r = {DerivedMapKind::Cavity, false, + Ogre::ColourValue(0.16f, 0.13f, 0.10f, 1.0f), + PaintLayerBlend::Mode::Normal, "Crevice dirt"}; + break; + case 2: // AO darken: multiply the occlusion over BaseColor. + r = {DerivedMapKind::AmbientOcclusion, false, + Ogre::ColourValue(0.0f, 0.0f, 0.0f, 1.0f), + PaintLayerBlend::Mode::Multiply, "AO darken"}; + break; + default: + m_derivedMapStatus = QStringLiteral("Unknown recipe."); + emit derivedMapChanged(); + return false; + } + + // Bake/load the recipe's own map, which may differ from the UI selection. + const int userKind = m_derivedMapKind; + const bool userInvert = m_derivedMapInvert; + m_derivedMapKind = static_cast(r.kind); + m_derivedMapInvert = r.invert; + const bool haveMap = derivedMapReady() || computeDerivedMap(); + if (!haveMap) { + m_derivedMapKind = userKind; // restore the user's selection + m_derivedMapInvert = userInvert; + emit derivedMapChanged(); + return false; + } + + const int W = m_buffer.width(); + const int H = m_buffer.height(); + if (W <= 0 || H <= 0) { + m_derivedMapKind = userKind; + m_derivedMapInvert = userInvert; + return false; + } + + // A flat colour layer; the MASK is what shapes it, so the user can paint + // over it afterwards to refine. + TexturePaintBuffer fill; + fill.resize(W, H); + for (int y = 0; y < H; ++y) + for (int x = 0; x < W; ++x) + fill.setPixel(x, y, r.colour); + + const auto before = m_layerStack.snapshot(); + const int idx = m_layerStack.addFromBuffer(fill, QLatin1String(r.name), + PaintLayerStack::LayerType::Generated); + if (idx < 0) { + m_derivedMapKind = userKind; + m_derivedMapInvert = userInvert; + return false; + } + m_layerStack.setActiveIndex(idx); + m_layerStack.setBlendMode(idx, r.blend); + + const DerivedMap* map = activeDerivedMap(); + std::vector& mask = m_layerStack.ensureLayerMask(idx); + if (map && mask.size() == static_cast(W) * H) { + for (int y = 0; y < H; ++y) { + const float v = (y + 0.5f) / static_cast(H); + for (int x = 0; x < W; ++x) { + const float u = (x + 0.5f) / static_cast(W); + float m = map->sample(u, v); + if (r.invert) m = 1.0f - m; + mask[static_cast(y) * W + x] = + static_cast(std::lround(std::clamp(m, 0.0f, 1.0f) * 255.0f)); + } + } + } + + m_derivedMapKind = userKind; // recipes must not hijack the picker + m_derivedMapInvert = userInvert; + + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QLatin1String(r.name), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + ++m_layerPreviewVersion; + SentryReporter::addBreadcrumb("paint.derived_map.recipe", QLatin1String(r.name)); + m_derivedMapStatus = QStringLiteral("%1 layer added.").arg(QLatin1String(r.name)); + emit layersChanged(); + emit fullResPreviewChanged(); + emit derivedMapChanged(); + return true; +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 2dfbc126..3a8f9a0c 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -12,6 +12,7 @@ #include "SymmetryMirrorMap.h" #include "ProjectionPainter.h" #include "DecalSession.h" +#include "DerivedMapGenerator.h" #include #include @@ -33,6 +34,7 @@ #include #include #include +#include class EditableMesh; class OgreWidget; @@ -147,6 +149,14 @@ class TexturePaintController : public QObject // Paint v2 Slice F (#549) — decal tool (read-only status for the panel). Q_PROPERTY(bool decalSessionActive READ decalSessionActive NOTIFY projectionChanged) Q_PROPERTY(int decalState READ decalState NOTIFY projectionChanged) + // Paint v2 Slice G (#550) — cavity / curvature / AO derived maps. + Q_PROPERTY(int derivedMapKind READ derivedMapKind WRITE setDerivedMapKind NOTIFY derivedMapChanged) // 0 cavity / 1 curvature / 2 AO + Q_PROPERTY(bool derivedMapAsBrushMask READ derivedMapAsBrushMask WRITE setDerivedMapAsBrushMask NOTIFY derivedMapChanged) + Q_PROPERTY(double derivedMapStrength READ derivedMapStrength WRITE setDerivedMapStrength NOTIFY derivedMapChanged) // 0..1 blend of the modulation + Q_PROPERTY(bool derivedMapInvert READ derivedMapInvert WRITE setDerivedMapInvert NOTIFY derivedMapChanged) + Q_PROPERTY(double derivedMapContrast READ derivedMapContrast WRITE setDerivedMapContrast NOTIFY derivedMapChanged) + Q_PROPERTY(bool derivedMapReady READ derivedMapReady NOTIFY derivedMapChanged) // read-only status + Q_PROPERTY(QString derivedMapStatus READ derivedMapStatus NOTIFY derivedMapChanged) // read-only, for the panel public: enum BrushTool { @@ -475,6 +485,36 @@ class TexturePaintController : public QObject /// path calls cancelDecal/commitDecal from mainwindow. Q_INVOKABLE bool commitDecal(); Q_INVOKABLE void cancelDecal(); + + // --- Paint v2 Slice G (#550): cavity / curvature / AO derived maps --- + int derivedMapKind() const { return m_derivedMapKind; } + void setDerivedMapKind(int kind); + bool derivedMapAsBrushMask() const { return m_derivedMapAsBrushMask; } + void setDerivedMapAsBrushMask(bool on); + double derivedMapStrength() const { return m_derivedMapStrength; } + void setDerivedMapStrength(double v); + bool derivedMapInvert() const { return m_derivedMapInvert; } + void setDerivedMapInvert(bool on); + double derivedMapContrast() const { return m_derivedMapContrast; } + void setDerivedMapContrast(double v); + /// True when the ACTIVE kind's map is computed and matches the current mesh. + bool derivedMapReady() const; + QString derivedMapStatus() const { return m_derivedMapStatus; } + + /// Compute (or load from cache) the active kind's map for the painted mesh. + /// AO renders hemisphere depth views, so this must run on the main thread. + /// Returns false and sets derivedMapStatus on failure. + Q_INVOKABLE bool computeDerivedMap(); + /// Drop cached maps for the painted mesh and recompute the active kind + /// ("Recalculate derived maps"). + Q_INVOKABLE bool recomputeDerivedMaps(); + /// Initialise the ACTIVE layer's mask from the active derived map, so the + /// user can then paint freely and have it show only in crevices / on edges. + /// Undoable. Returns false if there is no session/map. + Q_INVOKABLE bool applyDerivedMapToLayerMask(); + /// One-click recipes (#550): create a new masked layer preloaded with a + /// sensible colour. 0 = edge wear, 1 = crevice dirt, 2 = AO darken. + Q_INVOKABLE bool applyDerivedMapRecipe(int recipe); /// @} /// @name Paint v2 Slice D — PBR channel painting (#547) @@ -767,6 +807,8 @@ class TexturePaintController : public QObject void stabilizerChanged(); /// Paint v2 Slice F (#549): projection mode / stencil / lock state changed. void projectionChanged(); + /// Paint v2 Slice G (#550): derived-map settings / readiness / status changed. + void derivedMapChanged(); /// Emitted when the mouse hovers over a UV-mapped triangle (from /// the 3D mesh or from the 2D texture preview panel). u,v in [0..1]; /// (-1, -1) means "no hover". @@ -1163,6 +1205,31 @@ class TexturePaintController : public QObject std::vector m_projTris; // world tris cached per stroke bool m_haveProjTris = false; + // --- Paint v2 Slice G (#550): derived maps --- + int m_derivedMapKind = 0; // DerivedMapKind as int + bool m_derivedMapAsBrushMask = false; // modulate brush colour by the map + double m_derivedMapStrength = 1.0; // 0..1 blend of the modulation + bool m_derivedMapInvert = false; // e.g. edge wear = inverse cavity + double m_derivedMapContrast = 1.0; + QString m_derivedMapStatus; + /// Cached maps for the CURRENT mesh, keyed by kind. Cleared when the mesh + /// hash changes, so a topology edit cannot leave a stale map bound. + std::map m_derivedMaps; + QString m_derivedMapMeshHash; // hash the cached maps belong to + + /// Mesh hash for the painted mesh, or empty when there is no mesh. + QString currentMeshHash() const; + /// Drop in-memory maps when the mesh geometry no longer matches them. + void invalidateDerivedMapsIfMeshChanged(); + /// The active kind's map, or nullptr when not computed. + const DerivedMap* activeDerivedMap() const; + /// Sample the active map at `uv` and fold in invert/strength. Returns 1.0 + /// (no modulation) when no map is active, so callers need no special case. + float derivedMapFactorAt(const Ogre::Vector2& uv) const; + /// Render hemisphere depth views of the painted entity and reduce them to + /// per-welded-vertex occlusion. Main thread (touches the Ogre scene). + std::vector computeVertexOcclusion(QString* errorOut) const; + // --- Paint v2 Slice F (#549): decal tool --- DecalSession m_decal; Ogre::SceneNode* m_decalNode = nullptr; From 8940f02f2b16a2c3600d02b76a183449490ee378 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 25 Aug 2026 03:06:15 -0400 Subject: [PATCH 3/6] test(#550): Slice G controller tests, cache isolation + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Controller tests** (8 fixture cases): setter clamping, no-op writes not notifying (the panel mirrors these props, so churn matters), readiness AGREEING with bake success (claiming ready after a failed bake would strand the brush mask), every Q_INVOKABLE degrading safely with no session, a recipe restoring the user's kind+invert, and two brush-mask cases. The brush-mask pair is split deliberately after mutation testing showed the first version proved less than it looked like: - `...WithNoMapDoesNotBlockPainting` compares real PIXELS, not just hasActiveSession() — but it only covers the NO-MAP path, because `derivedMod` requires a non-null map, so the wrapper is never installed and derivedMapFactorAt is never reached. A mutant returning factor 0 passed this test, which is why the comment now states its scope explicitly. - `...WithMapModulatesCoverage` bakes a real map and paints at strength 1 vs strength 0, asserting the documented no-op can never paint less than a masked pass. This is the case that actually exercises the wrapper. **Cache test isolation** — the cache writes under , i.e. the user's real data directory. The suite now wraps those tests in a fixture using QStandardPaths::setTestModeEnabled (the guard BrushAssetLibrary_test and GamificationManager_test already use) and removes the cache root in TearDown. Without it the tests polluted a real install AND read stale entries back: a `derivedMapReady()` assertion failed only because a cavity.bin from an earlier RUN was still on disk — the cache working correctly, not a bug. The polluted directory this created has been removed. **Docs** — `docs/PAINT_V2_SLICE_G_DESIGN.md` covers the three maps, why AO uses depth-map visibility instead of the ray tracer the issue specified (no BVH/kd-tree exists and every ray query is a linear scan), why rasterisation is a local float implementation rather than VertexColorBaker (RGBA8 would band an AO gradient at 8 bits), the content-hash invalidation replacing the non-existent revision counter, and the known limits (main-thread AO bake, 12-view/256px quality ceiling, per-vertex detail bounded by mesh density). CLAUDE.md gains the matching architecture entry. Tests: 45/45 (13 generator + 9 cache + 15 occlusion + 8 controller). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 1 + src/DerivedMapCache_test.cpp | 35 +++-- src/TexturePaintController_test.cpp | 221 ++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0166af90..5584abfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,6 +234,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Paint v2 Slice D — full PBR channel painting (#547)**: paint into any of 8 channels — **`PaintChannelNS::Channel { BaseColor, Normal, Roughness, Metallic, AO, Emissive, Height, VertexColor }`** (`src/PaintChannel.h`, pure-data + unit-tested; each maps to a canonical TUS slot `albedo`/`normal_map`/`roughness`/`metallic`/`ao`/`emissive`, Height→normal_map, VertexColor→the existing per-vertex path). Architecture: **per-channel sessions** — the live `m_layerStack`/`m_buffer` is the active channel's session; `setActiveChannel()` **stashes** the current channel's layer stack into `m_channelSessions[channel]` and **restores** the target's (so each channel keeps its own stack across switches). Channel router: `refreshSlots()` tags each TUS with its channel; `findOrCreateActiveTextureUnit()` **auto-creates** the canonical slot when the asset never shipped it. **Channel-aware bake (`bakeChannel`) — every channel MERGES with the slot's existing texture, never a blind overwrite** (so a bake only changes what the user painted): colour channels (BaseColor/Emissive) composite the painted strokes **source-over the slot's current texture** (reading it back via `loadImageAcrossGroups`, alias-aware for BaseColor's `diffuse_map`, and **skipping the transient `QMEPaint_*` paint texture** so it reads the real underlying image, not the live paint buffer; a brand-new diffuse with no source flattens onto opaque white); **scalar** channels (Roughness/Metallic/AO) are painted grayscale and collapsed via Rec.601 luminance into ONLY their lane of the packed ORM texture (`.r=AO/.g=roughness/.b=metallic`, other lanes preserved from the existing ORM) bound to the `metallic` slot the Cook-Torrance SRS reads; **Height/Normal** Sobel-bakes the painted grayscale to a tangent-space detail normal via `NormalMapGenerator::generate` then **whiteout-blends it onto the existing `normal_map`** (`n.xy=base.xy+detail.xy`, `n.z=base.z*detail.z`, normalized — untouched texels have detail normal `(0,0,1)` so the base normal is unchanged; painted texels add relief). Height has no slot of its own — it bakes INTO `normal_map` (`slotName(Height)==""`), so a mesh carries one combined normal map, not separate height+normal. **Height is NOT a selectable channel** (removed from the `paintChannels()` picker; `setActiveChannel(Height)` redirects to Normal) — a separate Height channel just produced a second normal-map bake fighting the Normal one, and there is no parallax/displacement shader that would consume a standalone height texture. Paint the **Normal** channel directly (its grayscale is Sobel-converted to a detail normal and whiteout-blended onto the existing normal, sourcing the base from the session's `m_originalTextureName` first). Height was the highest picker index (enum 6, just before VertexColor 7), so dropping it leaves the remaining picker list indices identical to their `Channel` enum values — the QML `activeChannel === index` binding stays correct with no renumbering. Live-IBL binding recipe (`bindBakedChannelTexture`): bind TUS by canonical name (alias-aware for the diffuse slot) → `RTShaderHelper::wirePbrSlotsForFFP` → `MeshImporterExporter::applyNormalMapsToEntity` (tangents, for normal maps) → `applyPbrIfTagged` **only if the material was ALREADY PBR** (never silently promote a plain material to Cook-Torrance on bake — that darkened it to near-black without IBL) → `mat->compile()` → `refreshAllPbrMaterialsForHdr()`. **Critically, `bindBakedChannelTexture` prunes the just-baked slot from `m_boundSlots` and then TEARS THE LIVE SESSION DOWN** (`stashChannelSession` + `m_buffer.clearDirty()` + `closeSession()`) instead of calling `flushDirtyToOgre()` — a trailing flush would re-upload the stale paint buffer and, since the slot was just pruned, its deferred-rebind branch would re-bind the transient `QMEPaint_*` texture straight back over the freshly-baked file (that was the "first bake changes the render, second bake wipes the texture" bug). The next brush stroke lazily rebuilds a session seeded from the baked result. Source-less channel sessions also start **transparent** (not opaque white) and defer the manual-paint-texture model rebind to the first painted stroke, so merely NAVIGATING channels never swaps the model's real slot textures for a blank paint texture. **Presets** (`src/PaintChannelPresets.h/cpp`, QML_SINGLETON, `MaterialPresetLibrary` pattern): 5 channel-aware presets — "Scratches into roughness", "Emissive sparks", "Edge wear", "Dirt build-up", "Sticker" — set active channel + brush params via the controller. UI: a 7-button channel picker + presets dropdown + per-channel "Bake" button in `texPaintCol`. Tests: `PaintChannel_test.cpp`, `PaintChannelPresets_test.cpp` (pure-data), plus `TexturePaintController_test.cpp` scene-fixture cases (channel switch auto-creates slot, scalar bake → ORM, height bake → normal_map, per-channel session isolation). **Session scoping**: `m_channelSessions` is cleared when the painted entity changes (`m_channelSessionEntity`) so one mesh's channel stacks never leak/bake onto another. **Undo across channels**: paint undo commands (`TexturePaintStrokeCommand`/`PaintLayerOpCommand`/`TexturePaintMaskActionCommand`) key on a stable `(Ogre::Entity*, channel)` — NOT the transient `QMEPaint_*` GPU texture name (which changes every channel/session switch). `undo()`/`redo()` call `ensureUndoTarget(entity, channel)` which reselects the entity + `setActiveChannel` before applying the snapshot, so undoing a prior channel's stroke after switching channels (or deselecting the mesh) correctly reactivates and restores it; a command whose entity was deleted no-ops safely. - **Paint v2 Slice E — symmetry + line stabilizer (#548)**: real-time mirrored strokes and mouse-jitter smoothing. **Symmetry**: WRITE-backed props `symmetryEnabled` (default OFF), `symmetrySpace` (`SymLocal`/`SymWorld`, default local), `symmetryAxes` (bitmask `SymAxisX=1|Y=2|Z=4`, default X → 8 combinations), `topologyMirror` (default on). Every dab is mirrored across each enabled **axis-subset** (1 point for X, 3 for X|Y, 7 for X|Y|Z — `mirrorLocalPoints` iterates nonzero subsets) *inside the same begin/end stroke window*, so mirror dabs are captured by the existing single `TexturePaintStrokeCommand` — **one undo step, no new command**. Works from BOTH the viewport (screen raycast → `m_hitCache`) and the 2D panel (`applyBrushSymmetryDabs` falls back to `findMeshPointForUV`, which also seeds the hit cache). **Geometric resolver** `uvForLocalPoint` (inverse of `findMeshPointForUV`): reflect the primary LOCAL hit across the axis — local: about the mesh origin `m_symmetryPivotLocal`; world: about the plane through the entity's `_getDerivedPosition()` (NOT world 0, else off-origin objects mirror into empty space) — then nearest-triangle → barycentric → UV. **Topology-aware mirror** (`src/SymmetryMirrorMap.{h,cpp}`, pure-data + unit-tested): for a position-symmetric mesh with an ASYMMETRIC UV unwrap, a geometric re-raycast reads the wrong UV; instead `build()` makes a per-vertex position correspondence (spatial-hash grid) **verified by triangle 1-ring adjacency** (rejects false position collisions; `coverage()`≥0.6 AND verify-ratio≥0.9 to be `valid()`), and `mirrorDab(submesh, corner[3], bary → mirror tri + permuted bary)` maps the dab to the mirror triangle and **permutes the barycentric weights to that triangle's stored corner order** (reflection reverses winding — mandatory) so the mirror samples the mirror triangle's own UVs. Per-single-axis map cached in `m_symmetryMaps` keyed by axis bit, built **lazily on first symmetric dab**, entity-guarded (`m_symmetryMapEntity`), invalidated on entity/mesh change + `closeSession`; falls back to geometric per-dab when no correspondence. **Multi-axis segment continuity (E-B)**: per-subset previous mirror UV (`m_mirrorPrevUV`/`m_mirrorHavePrevUV`, reset in `resetStrokePaintState`) so each mirror stroke fans a `paintBrushAlongSegment` and doesn't gap on fast moves. **Plane viz**: `refreshSymmetryPlaneOverlay` draws a faint translucent quad per enabled axis (X=red/Y=green/Z=blue, alpha 0.12, depth-write off) on `m_symPlaneNode/Obj` (mirrors `drawHoverRingAt`), torn down in `closeSession`. **Stabilizer**: `stabilizerMode` (`StabAverage`/`StabTrail`), `stabilizerAmount` (0..100, default **0 = exact passthrough / zero latency**). Smooths the raw SCREEN cursor before hit-testing in `updateStroke`; `beginStroke` seeds the buffer with the press point (first dab exact); `endStroke` does a **synchronous** catch-up to the true cursor *before* the deferred undo commit (Krita behaviour — stays one undo step; NEVER a timer). Pure math (`stabilizerWindow`/`stabilizeAveragePoint` weighted newest-heaviest/`stabilizeTrailPoint` lag-distance) is `static` for unit testing. Sentry breadcrumbs `paint.symmetry` (enable/space/axes/topology/map-build) + `paint.stabilizer` (mode/amount, once per stroke). QML: Symmetry + Stabilizer groups in `texPaintCol`. Tests: `SymmetryMirrorMap_test.cpp` (full-coverage build, **asymmetric-UV correctness**, no-correspondence invalid) + `TexturePaintStabilizer_test.cpp` (window growth, jitter reduction, trail lag/catch-up, amount-0 passthrough, setter clamp) — pure-data, plus a `TexturePaintController_test.cpp` one-undo fixture case. - **Paint v2 Slice F — projection/stencil painting + decals (#549)**: two image-driven paint modes that project an image onto the mesh THROUGH a camera, rasterize it in UV0 space, and write into a paint layer; both auto-create a NEW layer (never stomp the active one). Design doc: `docs/PAINT_V2_SLICE_F_DESIGN.md`. **`src/ProjectionMath.h`** (header-only) extracts the shared `projectToViewportUV` (world→viewport UV + NDC z + behind) / `sampleImage` out of `MultiViewTextureBaker` so both the #403 baker and this slice share one definition. **`src/ProjectionPainter.{h,cpp}`** (pure-data, unit-tested) is the FORKED single-projection rasterizer (NOT a mutation of `MultiViewTextureBaker::bake`, whose multi-view weighted accumulator is the wrong shape): `project(tris, View, source, out, opts, occ?)` clears `out` transparent then per texel interpolates world pos + projected source UV → facing cull → occlusion/depth-limit → sample → soft-edge → **manual src-over composite**; `projectDab(...)` is the footprint-bounded ACCUMULATING stencil-brush variant. Tris come from `MultiViewTextureBaker::fromEntity`. **Occlusion (the hard part)**: `MeshDepthRenderer` renders a depth map from the camera (grayscale = LINEAR world distance via fog, near=bright/far=dark; `RenderResult` gained `depthNear/depthFar`); per texel, project world pos through the depth map's OWN `viewProj`, reconstruct `dMap = near+(1-g)*(far-near)`, and reject when the texel's **camera-axis** distance `dot(wp-eye, camDir)` (NOT Euclidean — Euclidean self-occludes off-axis texels, i.e. depth acne) exceeds `dMap + bias` (bias > 1/255 of the range). Depth-limit rejects texels farther than a fraction-of-bounds-radius BEHIND the nearest visible surface (the sphere-with-hole case). The color `View` and the occlusion `viewProj` are kept strictly separate (the depth camera auto-frames, so its matrices differ from the live camera's). **Projection surfaces** (`TexturePaintController`): WRITE-backed props `projectionMode` (0 off / 1 stencil-brush / 2 camera-locked), `stencilImagePath`, `projBackfaceCull`, `projUseOcclusion`, `projDepthLimit`, read-only `cameraLocked`. Stencil brush: `paintColorFootprintAtUV` delegates to `projectDab` through the live (mode 1) or locked (mode 2) camera View — masked by the projected stencil alpha, painted into the active layer (normal stroke undo). `m_projTris` cached at `beginStroke`; occlusion map refreshed at stroke start (mode 1) / on `snapProjectionCamera` (mode 2), never per dab. `projectFromPhoto` projects a photo through the camera into a scratch buffer and commits via `commitProjectedLayer` (new `Generated` layer + one `PaintLayerOpCommand`). `currentProjectionView` = `cam->getProjectionMatrixWithRSDepth()*getViewMatrix()` + `getRealDirection/Position`. **Decal tool** (`ToolDecal=6`): **`src/DecalSession.{h,cpp}`** (pure-data) is a world-anchored oriented-quad state machine — `begin(img)→place(surfaceHit,normal,camUp)→Editing`; `translate/rotate(about normal)/scale`; `hitTest(rectUv)→Body/RotateCorner/ScaleEdge`; `buildCommit(softEdge)` returns an ORTHOGRAPHIC `View` (world→clip change-of-basis mapping the quad to NDC ±1, camDirection into the surface) + the decal image with a feathered soft-edge alpha. Controller: `refreshDecalOverlay` draws a world-space `ManualObject` quad + corner(rotate)/edge(scale) handle squares (depth-off so grabbable), torn down in `closeSession`; `placeDecalAt`/`decalHitTest`(ray∩rect-plane→rect-UV)/`dragDecal`/`commitDecal`(→`ProjectionPainter::project`+occlusion→new layer)/`cancelDecal`. Viewport routing: `TransformOperator::mousePressEvent` consumes decal clicks BEFORE the paint-stroke branch (place while Placing, grab a handle while Editing), `mouseMoveEvent` drags, release ends the drag (session stays open); `MainWindow::keyPressEvent` — Enter commits / Esc cancels (swallow-all, mirrors the knife). Both create a new layer + are undoable. Sentry `paint.projection.*` / `paint.decal.*`. QML: a **collapsible** Projection group (mode/stencil/Snap/Backface/Occlude/Project-from-photo) + a Decal row (Place/Commit/Cancel + hint) in `texPaintCol`. Tests: `ProjectionPainter_test.cpp` (front projection, backface, stencil gating, dab, **sphere-with-hole occlusion**, depth-limit, self-projection no-acne) + `DecalSession_test.cpp` (transitions, hit-test zones, edits, world↔rect-UV, ortho-commit corner→NDC, soft-edge) — pure-data; plus `TexturePaintController_test.cpp` fixture cases (projection-mode setters + graceful no-camera; decal begin/cancel plumbing). +- **Paint v2 Slice G — cavity / curvature / AO derived maps (#550)**: auto-generated per-mesh scalar maps that gate the brush or initialise a layer mask — the canonical edge-wear / crevice-dirt / weathering workflow. Design doc: `docs/PAINT_V2_SLICE_G_DESIGN.md`. **`src/DerivedMapGenerator.{h,cpp}`** (pure-data, unit-tested): per-vertex concavity = mean over the `HalfEdgeMesh` 1-ring of `dot(vertexNormal, normalize(neighbour - v))` — positive = concave crevice, negative = convex ridge (the mesh is welded across submeshes first so a UV seam / material split does not read as a crease); `remapForKind` then makes **Cavity** the concave half only (grime never lands on ridges) and **Curvature** a signed signal centred at 0.5 with a `flatTolerance` that pins near-flat to EXACTLY neutral (else tessellation noise on flat panels speckles into visible edge wear). Rasterisation is a LOCAL float edge-function rasteriser + coverage-vector seam dilation, deliberately NOT `VertexColorBaker::rasterizeTriangle` (RGBA8-typed → a scalar map would quantise to 8 bits and band visibly across a smooth AO gradient); the explicit-coverage discipline IS copied, because a texel whose value equals the background is otherwise indistinguishable from an unwritten one. `generate()` REFUSES `AmbientOcclusion` (pointing at `fromVertexOcclusion`) so the whole rasterisation path stays headless-testable. **`src/DerivedMapOcclusion.{h,cpp}`** (pure-data): AO WITHOUT a ray tracer — there is no BVH/kd-tree/octree in the repo and every existing ray query is a brute-force linear scan, so instead of adding an acceleration structure this reuses the depth-map visibility test `ProjectionPainter::OcclusionMap` already proves: render depth from 12 Fibonacci-lattice directions (a naive lat/long grid clusters at the poles and biases AO vertically) and count how many views can see each vertex. Distance is compared along the **camera axis** (the fog encodes linear distance along it; Euclidean self-occludes off-axis points). Two non-obvious rules, both test-pinned: back-facing views are **SKIPPED, not counted as occluding** (counting them darkens every vertex ~half regardless of geometry), and when NO view faces the normal the answer is **0 (unoccluded), not 1** (which would black out uncovered regions). A behind-eye-plane guard is explicit because `projectToViewportUV`'s perspective `behind` (w<=0) flag NEVER fires under the ORTHOGRAPHIC-ish auto-framed depth views (w stays 1), so a point behind the camera got a negative axis distance that sailed through the `<= dMap + bias` test. **`src/DerivedMapCache.{h,cpp}`**: versioned `/paint/derived_maps//.bin` following `HdrCache`'s magic+version header + 40-hex-char key validation (the key is a path component, so `../` is made UNREPRESENTABLE rather than sanitised) + temp-file-then-rename (an interrupted save must never leave a half-entry a later load trusts). **Invalidation is by CONTENT HASH, not the "EditableMesh revision counter" the issue proposed — no such counter exists, and `EditableMesh`'s public mutable `subMeshes()` accessor means one could be bypassed without incrementing.** The SHA-1 already needed for the directory name IS the invalidation; it covers positions/normals/UV0/indices and deliberately EXCLUDES vertex colour + bone weights (they cannot change these maps, so including them would force needless rebakes). Bump `kFormatVersion` when a generator's output changes for identical input — the mesh hash cannot notice that. **Controller**: WRITE-backed `derivedMapKind`/`derivedMapAsBrushMask`/`derivedMapStrength`/`derivedMapInvert`/`derivedMapContrast` + read-only `derivedMapReady`/`derivedMapStatus`; `computeDerivedMap()` walks memory → disk cache → bake; `recomputeDerivedMaps()` is the issue's "Recalculate derived maps". `derivedMapReady()` returns false while the cached hash and live geometry disagree, so a topology edit can never leave a stale map bound. `setDerivedMapContrast` clears the cache (contrast feeds the GENERATOR, not the lookup). **Brush modulation wraps the `colorAt` callback** (so tiling/stamp/gradient inherit it for free, mirroring the TilingSource wrap) and scales **ALPHA, not RGB** (scaling RGB drags paint toward black in cavities instead of hiding it); the two scalar fast paths (solid colour, `GradientLinear`) collapse the brush to one colour via the `paintBrush` overload that never calls `colorAt`, so they are SKIPPED while a map modulates — otherwise enabling the mask appears to do nothing for the most common brush setup. `applyDerivedMapToLayerMask()` fills `PaintLayerStack::ensureLayerMask` (which existed with the compositor honouring `maskAlpha` but had NO non-test caller until this slice), sampling by UV rather than assuming 1:1 texels. Recipes (`applyDerivedMapRecipe`): Edge wear (inverted curvature / bare metal), Crevice dirt (cavity / dark grime), AO darken (AO / black / Multiply) — each one masked `Generated` layer + one undo step, and each RESTORES the user's kind+invert afterwards so a recipe never silently retargets the picker. Sentry `paint.derived_map.*`. QML: a collapsible "Cavity / Curvature / AO" group in `texPaintCol`. Tests: `DerivedMapGenerator_test.cpp` (valley-vs-ridge opposite sign, cavity clamps convex away, curvature flat-pin, dilation grows coverage, wrong-sized input refused), `DerivedMapCache_test.cpp` (hash stable + geometry-sensitive + colour-insensitive, round-trip, per-kind isolation, `../` rejected, corrupt entry = clean miss), `DerivedMapOcclusion_test.cpp` (bias vs acne, back-facing skipped, no-facing-view = 0, Fibonacci spread) + `TexturePaintController_test.cpp` fixture cases (setter clamping/notify, readiness agrees with bake success, every entry point safe with no session, recipe preserves the user's kind). ### Scene Lighting (epic #482, Slice H #490) diff --git a/src/DerivedMapCache_test.cpp b/src/DerivedMapCache_test.cpp index 85ba596c..8933db4e 100644 --- a/src/DerivedMapCache_test.cpp +++ b/src/DerivedMapCache_test.cpp @@ -20,6 +20,7 @@ The MIT License — see other project sources for the full header. #include #include +#include namespace { @@ -51,7 +52,23 @@ DerivedMap makeMap(int w, int h, float fill) } // namespace -TEST(DerivedMapCacheTest, MeshHashIsStableAndGeometrySensitive) { +// The cache writes under , which is the USER's real data directory. +// QStandardPaths test mode redirects it to a throwaway location (the same guard +// BrushAssetLibrary_test / GamificationManager_test use), so running the suite +// never pollutes — or reads stale entries from — a real install. +class DerivedMapCacheTest : public ::testing::Test +{ +protected: + void SetUp() override { QStandardPaths::setTestModeEnabled(true); } + void TearDown() override + { + const QString root = DerivedMapCache::cacheRootDirectory(); + if (!root.isEmpty()) QDir(root).removeRecursively(); + QStandardPaths::setTestModeEnabled(false); + } +}; + +TEST_F(DerivedMapCacheTest, MeshHashIsStableAndGeometrySensitive) { const QString a = DerivedMapCache::meshHash(triMesh(0.0f)); const QString b = DerivedMapCache::meshHash(triMesh(0.0f)); const QString c = DerivedMapCache::meshHash(triMesh(5.0f)); @@ -61,7 +78,7 @@ TEST(DerivedMapCacheTest, MeshHashIsStableAndGeometrySensitive) { EXPECT_NE(a, c) << "moved geometry must hash differently — this IS the invalidation"; } -TEST(DerivedMapCacheTest, HashIgnoresNonGeometricAttributes) { +TEST_F(DerivedMapCacheTest, HashIgnoresNonGeometricAttributes) { // Vertex colour cannot change cavity/curvature/AO, so it must not force a // rebake. If this ever starts failing, painting a vertex colour would // silently invalidate every derived map for that mesh. @@ -72,7 +89,7 @@ TEST(DerivedMapCacheTest, HashIgnoresNonGeometricAttributes) { EXPECT_EQ(DerivedMapCache::meshHash(m1), DerivedMapCache::meshHash(m2)); } -TEST(DerivedMapCacheTest, HashChangesWithUvAndNormals) { +TEST_F(DerivedMapCacheTest, HashChangesWithUvAndNormals) { // UV and normals DO affect the output (UV decides where texels land, // normals decide the concavity sign), so both must be in the hash. EditableMesh uvChanged = triMesh(); @@ -84,7 +101,7 @@ TEST(DerivedMapCacheTest, HashChangesWithUvAndNormals) { EXPECT_NE(DerivedMapCache::meshHash(triMesh()), DerivedMapCache::meshHash(nChanged)); } -TEST(DerivedMapCacheTest, SaveLoadRoundTripsExactly) { +TEST_F(DerivedMapCacheTest, SaveLoadRoundTripsExactly) { const QString key = DerivedMapCache::meshHash(triMesh()); DerivedMapCache::invalidateAll(key); @@ -104,7 +121,7 @@ TEST(DerivedMapCacheTest, SaveLoadRoundTripsExactly) { DerivedMapCache::invalidateAll(key); } -TEST(DerivedMapCacheTest, KindsAreStoredSeparately) { +TEST_F(DerivedMapCacheTest, KindsAreStoredSeparately) { const QString key = DerivedMapCache::meshHash(triMesh()); DerivedMapCache::invalidateAll(key); QString err; @@ -126,7 +143,7 @@ TEST(DerivedMapCacheTest, KindsAreStoredSeparately) { EXPECT_FALSE(DerivedMapCache::has(key, DerivedMapKind::Curvature)); } -TEST(DerivedMapCacheTest, MalformedKeysAreRejectedAsPaths) { +TEST_F(DerivedMapCacheTest, MalformedKeysAreRejectedAsPaths) { // The key becomes a path component, so traversal attempts and wrong-length // keys must be structurally impossible rather than sanitised. for (const char* bad : {"../../etc/passwd", "not-hex-at-all", "", "abc", @@ -144,7 +161,7 @@ TEST(DerivedMapCacheTest, MalformedKeysAreRejectedAsPaths) { QStringLiteral("0123456789abcdef0123456789abcdef01234567")).isEmpty()); } -TEST(DerivedMapCacheTest, RefusesToCacheEmptyMap) { +TEST_F(DerivedMapCacheTest, RefusesToCacheEmptyMap) { const QString key = DerivedMapCache::meshHash(triMesh()); QString err; // An empty map means the generator failed; caching it would poison every @@ -153,7 +170,7 @@ TEST(DerivedMapCacheTest, RefusesToCacheEmptyMap) { EXPECT_FALSE(err.isEmpty()); } -TEST(DerivedMapCacheTest, CorruptEntryIsAMissNotACrash) { +TEST_F(DerivedMapCacheTest, CorruptEntryIsAMissNotACrash) { const QString key = DerivedMapCache::meshHash(triMesh()); DerivedMapCache::invalidateAll(key); QString err; @@ -173,7 +190,7 @@ TEST(DerivedMapCacheTest, CorruptEntryIsAMissNotACrash) { DerivedMapCache::invalidateAll(key); } -TEST(DerivedMapCacheTest, LoadOfMissingEntryFailsCleanly) { +TEST_F(DerivedMapCacheTest, LoadOfMissingEntryFailsCleanly) { const QString key = QStringLiteral("abcdefabcdefabcdefabcdefabcdefabcdefabcd"); DerivedMapCache::invalidateAll(key); DerivedMap out; diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp index 4121445c..6438be87 100644 --- a/src/TexturePaintController_test.cpp +++ b/src/TexturePaintController_test.cpp @@ -1413,3 +1413,224 @@ TEST_F(TexturePaintControllerSceneTest, DecalSessionBeginCancelPlumbing) { ctrl->closeSession(); } + +// --- Paint v2 Slice G (#550): derived maps ------------------------------- +// The generators/cache/occlusion maths are covered pure-data in +// DerivedMapGenerator_test / DerivedMapCache_test / DerivedMapOcclusion_test. +// These fixture cases cover the CONTROLLER contract: setters clamp + notify, +// readiness tracks the mesh hash, and every entry point degrades safely with no +// session rather than crashing or half-applying. + +TEST_F(TexturePaintControllerSceneTest, DerivedMapSettersClampAndPersist) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedSetters"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + + ctrl->setDerivedMapKind(1); + EXPECT_EQ(ctrl->derivedMapKind(), 1); + // Out-of-range kinds must clamp into the enum, not index past it. + ctrl->setDerivedMapKind(99); + EXPECT_EQ(ctrl->derivedMapKind(), 2); + ctrl->setDerivedMapKind(-5); + EXPECT_EQ(ctrl->derivedMapKind(), 0); + + ctrl->setDerivedMapStrength(2.5); + EXPECT_DOUBLE_EQ(ctrl->derivedMapStrength(), 1.0); + ctrl->setDerivedMapStrength(-1.0); + EXPECT_DOUBLE_EQ(ctrl->derivedMapStrength(), 0.0); + ctrl->setDerivedMapStrength(0.4); + EXPECT_NEAR(ctrl->derivedMapStrength(), 0.4, 1e-9); + + ctrl->setDerivedMapContrast(100.0); + EXPECT_LE(ctrl->derivedMapContrast(), 8.0); + ctrl->setDerivedMapContrast(0.0); + EXPECT_GE(ctrl->derivedMapContrast(), 0.1); + + ctrl->setDerivedMapInvert(true); + EXPECT_TRUE(ctrl->derivedMapInvert()); + ctrl->setDerivedMapAsBrushMask(true); + EXPECT_TRUE(ctrl->derivedMapAsBrushMask()); + + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapSettersEmitChangeSignal) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedNotify"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + + int hits = 0; + const auto conn = QObject::connect(ctrl, &TexturePaintController::derivedMapChanged, + [&hits]() { ++hits; }); + ctrl->setDerivedMapKind(ctrl->derivedMapKind() == 0 ? 1 : 0); + EXPECT_GT(hits, 0) << "the panel mirrors these props, so a change must notify"; + + // A no-op write must NOT notify (else the panel churns every frame). + const int after = hits; + ctrl->setDerivedMapKind(ctrl->derivedMapKind()); + EXPECT_EQ(hits, after); + + QObject::disconnect(conn); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapNotReadyBeforeBake) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedNotReady"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + // Nothing baked yet, so the panel's readiness dot must read false rather + // than advertising a map the brush would then fail to find. + EXPECT_FALSE(ctrl->derivedMapReady()); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapEntryPointsAreSafeWithoutASession) { + // Every Q_INVOKABLE is reachable from QML at any time, so each must fail + // cleanly with no session instead of dereferencing a null mesh/buffer. + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ctrl->closeSession(); + + EXPECT_FALSE(ctrl->derivedMapReady()); + EXPECT_FALSE(ctrl->computeDerivedMap()); + EXPECT_FALSE(ctrl->applyDerivedMapToLayerMask()); + // Unknown recipe index must be rejected, not indexed into the recipe table. + EXPECT_FALSE(ctrl->applyDerivedMapRecipe(99)); + EXPECT_FALSE(ctrl->applyDerivedMapRecipe(-1)); + // A status message should explain the refusal to the user. + EXPECT_FALSE(ctrl->derivedMapStatus().isEmpty()); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapCavityBakesAndBecomesReady) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedCavityBake"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + ctrl->setDerivedMapKind(0); // Cavity — geometric, no GL needed + const bool baked = ctrl->computeDerivedMap(); + // The fixture mesh is a simple quad; a bake either succeeds (and is then + // ready) or reports why. Both are acceptable, but the two must AGREE — + // "ready" while the bake failed would strand the brush mask. + EXPECT_EQ(baked, ctrl->derivedMapReady()); + EXPECT_FALSE(ctrl->derivedMapStatus().isEmpty()); + + if (baked) { + // A second call must be a cache hit, not a re-bake, and stay ready. + EXPECT_TRUE(ctrl->computeDerivedMap()); + EXPECT_TRUE(ctrl->derivedMapReady()); + } + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapBrushMaskWithNoMapDoesNotBlockPainting) { + // Enabling the mask before baking must not silently swallow every stroke: + // with no map the modulation factor is 1 (no-op), so painting still works. + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedMaskNoMap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + // setTexturePaintEnabled + hasActiveSession is the sequence the other stroke + // tests use; beginStrokeUV refuses without an enabled session. + ctrl->setTexturePaintEnabled(true); + ASSERT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapAsBrushMask(true); + ctrl->setDerivedMapStrength(1.0); + // NB deliberately NOT asserting !derivedMapReady() here: the on-disk cache + // persists across processes, so a cavity map baked by an earlier run (or an + // earlier test) legitimately loads and reports ready. What matters for this + // case is that the brush still paints — with a map the modulation applies, + // without one the factor is 1 (no-op); neither may swallow the stroke. + + // Compare actual PIXELS before/after, not just hasActiveSession(). + // NB this covers the NO-MAP path specifically: `derivedMod` requires a + // non-null map, so with nothing baked the wrapper is never installed and + // painting must proceed untouched. Modulation WITH a map is covered by + // DerivedMapBrushMaskWithMapModulatesCoverage below. + const QImage before = ctrl->snapshotBufferImage(); + ASSERT_FALSE(before.isNull()); + + ASSERT_TRUE(ctrl->beginStrokeUV(0.5, 0.5)); + ctrl->updateStrokeUV(0.55, 0.55); + ctrl->endStrokeUV(); + + const QImage after = ctrl->snapshotBufferImage(); + ASSERT_FALSE(after.isNull()); + ASSERT_EQ(before.size(), after.size()); + int changed = 0; + for (int y = 0; y < after.height() && changed == 0; ++y) + for (int x = 0; x < after.width(); ++x) + if (before.pixel(x, y) != after.pixel(x, y)) { ++changed; break; } + EXPECT_GT(changed, 0) + << "with no derived map the modulation factor must be 1 (a no-op); " + "a stroke that paints nothing means the mask is swallowing dabs"; + EXPECT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapAsBrushMask(false); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapRecipePreservesUserKindSelection) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedRecipeKind"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + ctrl->setDerivedMapKind(1); // user picked Curvature + ctrl->setDerivedMapInvert(false); + // "Crevice dirt" bakes CAVITY internally; whether or not it succeeds, it + // must not leave the picker pointing somewhere the user did not choose. + ctrl->applyDerivedMapRecipe(1); + EXPECT_EQ(ctrl->derivedMapKind(), 1) << "a recipe must restore the user's kind"; + EXPECT_FALSE(ctrl->derivedMapInvert()) << "a recipe must restore invert too"; + + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapBrushMaskWithMapModulatesCoverage) { + // Exercises the modulation wrapper for real: bake a map, then paint with the + // mask at full strength and again at zero strength. Strength 0 blends the + // factor back to 1 (a documented no-op), so it must paint at least as much + // as the masked pass. If the wrapper ever stopped being installed — or + // scaled RGB instead of ALPHA — these two would come out identical. + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedMaskWithMap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ctrl->setTexturePaintEnabled(true); + ASSERT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapKind(0); // Cavity (geometric, no GL) + if (!ctrl->computeDerivedMap()) { + // The fixture quad may yield no cavity signal; nothing to assert then. + ctrl->closeSession(); + GTEST_SKIP() << "no cavity signal on the fixture mesh"; + } + ASSERT_TRUE(ctrl->derivedMapReady()); + + const auto paintedTexels = [&](double strength) { + ctrl->setDerivedMapAsBrushMask(true); + ctrl->setDerivedMapStrength(strength); + const QImage before = ctrl->snapshotBufferImage(); + ctrl->beginStrokeUV(0.5, 0.5); + ctrl->updateStrokeUV(0.6, 0.6); + ctrl->endStrokeUV(); + const QImage after = ctrl->snapshotBufferImage(); + int n = 0; + if (!before.isNull() && !after.isNull() && before.size() == after.size()) { + for (int y = 0; y < after.height(); ++y) + for (int x = 0; x < after.width(); ++x) + if (before.pixel(x, y) != after.pixel(x, y)) ++n; + } + return n; + }; + + const int masked = paintedTexels(1.0); + const int unmasked = paintedTexels(0.0); + // Strength 0 is the documented no-op, so it can never paint LESS than a + // fully-masked pass. + EXPECT_GE(unmasked, masked); + EXPECT_GT(unmasked, 0) << "strength 0 must behave as no modulation at all"; + + ctrl->setDerivedMapAsBrushMask(false); + ctrl->closeSession(); +} From 9395b51059f875f3027290097e9ed692af093be8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 25 Aug 2026 03:06:54 -0400 Subject: [PATCH 4/6] docs(#550): add Slice G design doc (+ allowlist paint design docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/*` is gitignored with a per-file allowlist, so the new design doc was silently ignored by `git add`. Note docs/PAINT_V2_SLICE_F_DESIGN.md is tracked WITHOUT an allowlist entry — it predates the `docs/*` rule, so it stays tracked while any new sibling gets dropped. Allowlisted both, so Slice F's doc keeps working if it is ever re-added and Slice G's is tracked from the start. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 + docs/PAINT_V2_SLICE_G_DESIGN.md | 147 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 docs/PAINT_V2_SLICE_G_DESIGN.md diff --git a/.gitignore b/.gitignore index 9a56429a..cae07edb 100755 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,8 @@ __pycache__/ /multiview_bake_*.png /multiview_bake_*i.png !docs/SKINNING_QUALITY.md +!docs/PAINT_V2_SLICE_F_DESIGN.md +!docs/PAINT_V2_SLICE_G_DESIGN.md !docs/img !docs/img/twist_bar_rest_lbs.png !docs/img/twist_bar_90_lbs.png diff --git a/docs/PAINT_V2_SLICE_G_DESIGN.md b/docs/PAINT_V2_SLICE_G_DESIGN.md new file mode 100644 index 00000000..2fc9fc00 --- /dev/null +++ b/docs/PAINT_V2_SLICE_G_DESIGN.md @@ -0,0 +1,147 @@ +# Paint v2 Slice G — Cavity / Curvature / AO masks (#550) + +Auto-generated per-mesh **derived maps** that drive the classic weathering +workflow: dirt in crevices, wear on edges, shadowing in occluded areas. Each map +can gate the brush or initialise a layer mask, and three one-click recipes wire +up the common cases. + +Parent epic: #543. Depends on Slice C (#546, layers). + +## The three maps + +| Map | Meaning | Stored range | Typical use | +|---|---|---|---| +| **Cavity** | concave only | `0` flat/convex → `1` deep crevice | crevice dirt, grime | +| **Curvature** | signed | `0` convex ← `0.5` flat → `1` concave | edge wear (inverted) | +| **AO** | ambient occlusion | `0` open → `1` fully occluded | weathering, contact shadow | + +Cavity and curvature come from the same geometric signal: for each vertex, +average the dot of its normal with the direction to each 1-ring neighbour +(`HalfEdgeMesh::verticesAroundVertex`). Neighbours sitting *above* the tangent +plane mean the surface closes in — concave. Below — a convex ridge. + +- **Cavity** keeps only the concave half, so grime never lands on ridges. +- **Curvature** keeps the sign and centres flat at `0.5`, with a `flatTolerance` + that pins near-flat values to exactly neutral. Without it, tessellation noise + on nominally flat panels speckles into visible edge wear. + +The mesh is welded across submeshes first (via `HalfEdgeMesh`), so a UV seam or +a material split does not read as a crease. + +## AO without a ray tracer + +The issue specified "short-ray hemispherical occlusion" on the CPU. There is no +BVH, kd-tree or octree anywhere in the repo, and every existing ray query is a +brute-force linear scan — so a real ray AO would have meant adding an +acceleration structure. + +Instead AO reuses the **depth-map visibility test** already proven by +`ProjectionPainter::OcclusionMap` (#549): render the mesh's depth from 12 evenly +spread directions (Fibonacci lattice — a naive lat/long grid clusters at the +poles and biases AO vertically), then per vertex count how many of those views +can actually see it. That fraction is the occlusion. + +Two behaviours that matter, both pinned by tests: + +- **Back-facing views are skipped, not counted as occluding.** A view looking at + the back of a face cannot tell you how lit the front is; counting it would + darken every vertex by roughly half regardless of geometry. +- **When no view faces the normal the result is `0`** (unoccluded), not `1`. + Reporting "fully occluded" for a surface the view set happens not to cover + would black out whole regions of the map. + +The **bias** is `max(2 grayscale steps of the encoded range, 1% of the bounds +radius)`. Below that, depth quantisation alone makes a surface occlude itself +(depth acne). Distance is compared along the **camera axis**, not Euclidean, +because the depth map encodes linear fog distance along that axis. + +The visibility *maths* is pure data (a vertex + `DepthView`s → a scalar) and +unit-tested headlessly against synthetic depth images; only the rendering of +those views touches the Ogre scene. + +## Rasterisation + +Per-vertex scalars are rasterised into UV0 with an edge-function half-space +test, then **seam-dilated** — UV islands need this or bilinear/MIP sampling +bleeds background across the seams. + +This is a local float implementation rather than a call into +`VertexColorBaker::rasterizeTriangle`, which the issue suggested reusing. That +one is typed on RGBA8 `ColourValue`, so a scalar map would quantise to 8 bits +and band visibly across a smooth AO gradient. The *discipline* is copied +verbatim, including the explicit coverage vector: a texel whose value happens to +equal the background is otherwise indistinguishable from an unwritten one, so +dilation cannot infer coverage from "differs from background". + +## Cache and invalidation + +Maps live in `/paint/derived_maps//.bin`, with the +magic+version header and 40-hex-char key validation from `HdrCache` — the key +becomes a path component, so `../` is made *unrepresentable* rather than +sanitised. Writes go to a temp file and are renamed, so an interrupted save +cannot leave a half-written entry that a later load would trust. + +**Invalidation is by content hash, not a revision counter.** The issue proposed +invalidating "via `EditableMesh` revision counter", but no such counter exists — +and `EditableMesh` exposes a public mutable `subMeshes()` accessor, so any +counter could be bypassed without incrementing and would not be authoritative. +The SHA-1 already needed for the directory name *is* the invalidation: changed +geometry hashes differently and misses naturally, with nothing to keep in sync. + +The hash covers positions, normals, UV0 and indices. It deliberately **excludes** +vertex colour and bone weights, which cannot change any of these maps — +including them would force needless rebakes. + +Bump `DerivedMapCache::kFormatVersion` whenever a generator's output would change +for identical input (an algorithm tweak, a different remap curve). The mesh hash +alone cannot notice that. + +## Using a map + +**Gate the brush** — "Mask the brush" multiplies each dab by the map value at +the painted UV, so a stroke lands only in crevices or only on edges. It scales +the colour's **alpha**, not RGB: scaling RGB would drag paint toward black in +cavities instead of hiding it there. + +**Initialise a layer mask** — "Mask active layer" fills the active layer's +`maskAlpha` from the map. Paint freely afterwards; the mask keeps it in the right +places. Sampling is by UV, not 1:1 texels, since a map may be baked at a +different resolution than the paint buffer. + +**One-click recipes** — each adds its own masked `Generated` layer as a single +undo step: + +| Recipe | Map | Colour | Blend | +|---|---|---|---| +| Edge wear | curvature, inverted | light bare metal | Normal | +| Crevice dirt | cavity | dark grime | Normal | +| AO darken | AO | black | Multiply | + +A recipe temporarily switches the active kind to bake its own map and then +**restores the user's picker selection**, so clicking one does not silently +retarget the UI. + +## Files + +| File | Role | +|---|---| +| `src/DerivedMapGenerator.{h,cpp}` | concavity, remaps, scalar rasterise + dilate (pure data) | +| `src/DerivedMapOcclusion.{h,cpp}` | depth-map visibility → per-vertex AO (pure data) | +| `src/DerivedMapCache.{h,cpp}` | versioned on-disk cache + mesh hashing | +| `src/TexturePaintController.{h,cpp}` | properties, bake orchestration, brush/mask/recipes | +| `qml/PropertiesPanel.qml` | the collapsible "Cavity / Curvature / AO" group | + +Breadcrumbs: `paint.derived_map`, `.bake`, `.cache_hit`, `.cache_write_failed`, +`.error`, `.layer_mask`, `.recipe`, `.recalculate`. + +## Known limits + +- AO renders on the **main thread** (it drives the Ogre RTT), so a bake briefly + blocks the UI. It is cached, so the cost is paid once per geometry. +- AO quality is bounded by the 12-view count and the 256² depth resolution; + small contact details can be missed. Increasing either trades bake time. +- Curvature/cavity are per-**vertex** signals, so their detail is bounded by + mesh density — a low-poly mesh yields broad, soft masks. Sub-vertex detail + would need a per-texel normal-difference pass. +- The maps are baked in the mesh's own UV0 layout; a mesh with overlapping UVs + gets overlapping map data, exactly as any other UV-space bake would. From ea12ed5fd791c8b4dc3ac66855b1d710985e77f5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 25 Aug 2026 03:19:11 -0400 Subject: [PATCH 5/6] refactor(#550): split the recipe path to pre-empt the Sonar complexity gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #960's quality gate failed on new_maintainability_rating (C, needs A), driven by cpp:S3776 cognitive complexity. `applyDerivedMapRecipe` was 114 lines with the recipe table, the mask loop and the layer plumbing all inline — the same shape that tripped the gate last time. Split before CI reports it rather than after: - `fillMaskFromActiveMap()` — the UV-sampled mask fill, now shared by the layer-mask action and the recipes so both write a mask identically. It also removes a duplicated loop that had already drifted (the recipe copy applied invert but not strength). - `derivedRecipeFor()` in an anonymous namespace — the preset table, so the entry point holds only control flow. `applyDerivedMapRecipe` is now 77 lines. Recipes deliberately fill their mask at FULL strength (`applyStrength=false`): the strength slider governs the interactive brush mask, not a preset's baked-in look — previously implicit in the duplicated loop, now explicit at the call site. Also checked the new files for the other rules that gate flagged: zero bare `catch (...)` and zero `const_cast` across DerivedMapGenerator/Cache/Occlusion. Behaviour unchanged: 45/45 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/TexturePaintController.cpp | 129 +++++++++++++++++++-------------- src/TexturePaintController.h | 5 ++ 2 files changed, 78 insertions(+), 56 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index e6d7155a..04704079 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -7541,6 +7541,30 @@ bool TexturePaintController::recomputeDerivedMaps() return computeDerivedMap(); } +void TexturePaintController::fillMaskFromActiveMap(std::vector& mask, + int W, int H, + bool invert, + bool applyStrength) const +{ + const DerivedMap* map = activeDerivedMap(); + if (!map || W <= 0 || H <= 0) return; + if (mask.size() != static_cast(W) * static_cast(H)) return; + // Sample by UV, not 1:1 texels: a map may be baked at a different + // resolution than the paint buffer. + const float strength = applyStrength ? static_cast(m_derivedMapStrength) : 1.0f; + for (int y = 0; y < H; ++y) { + const float v = (y + 0.5f) / static_cast(H); + for (int x = 0; x < W; ++x) { + const float u = (x + 0.5f) / static_cast(W); + float m = map->sample(u, v); + if (invert) m = 1.0f - m; + m = std::clamp(1.0f + strength * (m - 1.0f), 0.0f, 1.0f); + mask[static_cast(y) * W + x] = + static_cast(std::lround(m * 255.0f)); + } + } +} + bool TexturePaintController::applyDerivedMapToLayerMask() { if (!hasActiveSession()) { @@ -7568,20 +7592,7 @@ bool TexturePaintController::applyDerivedMapToLayerMask() emit derivedMapChanged(); return false; } - // The map may have been baked at a different resolution than the paint - // buffer, so sample by UV rather than assuming a 1:1 texel mapping. - for (int y = 0; y < H; ++y) { - const float v = (y + 0.5f) / static_cast(H); - for (int x = 0; x < W; ++x) { - const float u = (x + 0.5f) / static_cast(W); - float m = map->sample(u, v); - if (m_derivedMapInvert) m = 1.0f - m; - const float s = static_cast(m_derivedMapStrength); - m = std::clamp(1.0f + s * (m - 1.0f), 0.0f, 1.0f); - mask[static_cast(y) * W + x] = - static_cast(std::lround(m * 255.0f)); - } - } + fillMaskFromActiveMap(mask, W, H, m_derivedMapInvert, /*applyStrength=*/true); recomposeComposite(/*fullBuffer=*/true); flushDirtyToOgre(); @@ -7599,6 +7610,49 @@ bool TexturePaintController::applyDerivedMapToLayerMask() return true; } +namespace { + +// One-click recipe presets (#550). Each is (which map, inverted?, fill colour, +// blend, layer name); the MASK is what shapes the layer, so the user can paint +// over it afterwards to refine. +struct DerivedRecipe { + DerivedMapKind kind; + bool invert; + Ogre::ColourValue colour; + PaintLayerBlend::Mode blend; + const char* name; +}; + +bool derivedRecipeFor(int recipe, DerivedRecipe& out) +{ + switch (recipe) { + case 0: + // Edge wear: bare metal on CONVEX ridges. Curvature stores convex + // BELOW 0.5, so the mask must be inverted to select ridges rather + // than crevices. + out = {DerivedMapKind::Curvature, /*invert=*/true, + Ogre::ColourValue(0.78f, 0.78f, 0.80f, 1.0f), + PaintLayerBlend::Mode::Normal, "Edge wear"}; + return true; + case 1: + // Crevice dirt: dark grime in concave areas => cavity as-is. + out = {DerivedMapKind::Cavity, /*invert=*/false, + Ogre::ColourValue(0.16f, 0.13f, 0.10f, 1.0f), + PaintLayerBlend::Mode::Normal, "Crevice dirt"}; + return true; + case 2: + // AO darken: multiply the occlusion over BaseColor. + out = {DerivedMapKind::AmbientOcclusion, /*invert=*/false, + Ogre::ColourValue(0.0f, 0.0f, 0.0f, 1.0f), + PaintLayerBlend::Mode::Multiply, "AO darken"}; + return true; + default: + return false; + } +} + +} // namespace + bool TexturePaintController::applyDerivedMapRecipe(int recipe) { if (!hasActiveSession()) { @@ -7610,35 +7664,8 @@ bool TexturePaintController::applyDerivedMapRecipe(int recipe) } } - // Each recipe = (which map, inverted?, fill colour, blend, layer name). - struct Recipe { - DerivedMapKind kind; - bool invert; - Ogre::ColourValue colour; - PaintLayerBlend::Mode blend; - const char* name; - }; - Recipe r; - switch (recipe) { - case 0: // Edge wear: bare metal on CONVEX edges => inverse curvature. - r = {DerivedMapKind::Curvature, false, - Ogre::ColourValue(0.78f, 0.78f, 0.80f, 1.0f), - PaintLayerBlend::Mode::Normal, "Edge wear"}; - // Curvature stores convex BELOW 0.5, so the mask must be inverted to - // select ridges rather than crevices. - r.invert = true; - break; - case 1: // Crevice dirt: dark grime in concave areas => cavity as-is. - r = {DerivedMapKind::Cavity, false, - Ogre::ColourValue(0.16f, 0.13f, 0.10f, 1.0f), - PaintLayerBlend::Mode::Normal, "Crevice dirt"}; - break; - case 2: // AO darken: multiply the occlusion over BaseColor. - r = {DerivedMapKind::AmbientOcclusion, false, - Ogre::ColourValue(0.0f, 0.0f, 0.0f, 1.0f), - PaintLayerBlend::Mode::Multiply, "AO darken"}; - break; - default: + DerivedRecipe r; + if (!derivedRecipeFor(recipe, r)) { m_derivedMapStatus = QStringLiteral("Unknown recipe."); emit derivedMapChanged(); return false; @@ -7684,20 +7711,10 @@ bool TexturePaintController::applyDerivedMapRecipe(int recipe) m_layerStack.setActiveIndex(idx); m_layerStack.setBlendMode(idx, r.blend); - const DerivedMap* map = activeDerivedMap(); + // A recipe uses its own map at FULL strength — the strength slider governs + // the interactive brush mask, not a preset's baked-in look. std::vector& mask = m_layerStack.ensureLayerMask(idx); - if (map && mask.size() == static_cast(W) * H) { - for (int y = 0; y < H; ++y) { - const float v = (y + 0.5f) / static_cast(H); - for (int x = 0; x < W; ++x) { - const float u = (x + 0.5f) / static_cast(W); - float m = map->sample(u, v); - if (r.invert) m = 1.0f - m; - mask[static_cast(y) * W + x] = - static_cast(std::lround(std::clamp(m, 0.0f, 1.0f) * 255.0f)); - } - } - } + fillMaskFromActiveMap(mask, W, H, r.invert, /*applyStrength=*/false); m_derivedMapKind = userKind; // recipes must not hijack the picker m_derivedMapInvert = userInvert; diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 3a8f9a0c..acfad396 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -1217,6 +1217,11 @@ class TexturePaintController : public QObject std::map m_derivedMaps; QString m_derivedMapMeshHash; // hash the cached maps belong to + /// Fill `mask` (sized W*H) from the active derived map, honouring + /// `invert`. Shared by the layer-mask action and the recipes so both write + /// the mask exactly the same way. + void fillMaskFromActiveMap(std::vector& mask, int W, int H, + bool invert, bool applyStrength) const; /// Mesh hash for the painted mesh, or empty when there is no mesh. QString currentMeshHash() const; /// Drop in-memory maps when the mesh geometry no longer matches them. From 38913f2f7a56450e95f80000a45ce4bdd6c3a82b Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 25 Aug 2026 04:02:29 -0400 Subject: [PATCH 6/6] fix(#550): register DerivedMap sources in the test library (Linux link failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's unit-tests-linux failed to LINK (not a test failure): undefined reference to `DerivedMapCache::invalidateAll(QString const&)' undefined reference to `DerivedMap::sample(float, float) const' undefined reference to `DerivedMapGenerator::kindName(DerivedMapKind)' `tests/CMakeLists.txt` keeps its OWN explicit source list for libqtmesh_test_common (not a glob), and I had only added the three new .cpp files to `src/CMakeLists.txt`. So TexturePaintController.cpp compiled into the test library with calls into symbols that library never compiled. This did not reproduce locally because the two paths differ: CI configures with -DBUILD_QT_MESH_EDITOR=OFF, which builds tests through tests/CMakeLists.txt, while my local `--target UnitTests` build went through src/CMakeLists.txt where the files were already registered. Worth remembering for any future file added under src/ that tests touch — registering it in one list is not enough. Verified each undefined symbol is defined in a file that is now listed: invalidateAll -> DerivedMapCache.cpp, sample + kindName -> DerivedMapGenerator.cpp (DerivedMapOcclusion.cpp added too, since TexturePaintController's AO path calls into it). Co-Authored-By: Claude Opus 5 (1M context) --- tests/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1ec9769b..4f734686 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,9 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ProjectionPainter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/DecalSession.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapGenerator.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapCache.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapOcclusion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MultiViewTextureBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBaker.cpp